Files
Moon-Household-Budget/debts.py
tonym be74ca1eb7 Harden JSON storage (atomic writes + crash-safe loads) + suggestions lightbulb
- storage.py: atomic save_json (temp+fsync+os.replace) and load_json that
  survives missing/corrupt files (moves corrupt aside). Closes #11/#12.
- route all data modules + app.py helpers through storage (closes #31 core);
  fixes mutable DEFAULT_FUNDS/DEFAULT_CARS return (#23).
- gunicorn --workers 1 --threads 4 to remove the write race (#13); strip
  Dockerfile template scaffolding (#40).
- base.html: 💡 suggestions link in the shared nav (shows on every page).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:34:06 -05:00

188 lines
5.5 KiB
Python

import json
import os
from datetime import date
import math
from storage import load_json, save_json
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data"))
DEBTS_FILE = os.path.join(DATA_DIR, "debts.json")
DEBT_LOG_FILE = os.path.join(DATA_DIR, "debt_log.json")
DEBT_CATEGORIES = ["Credit Cards", "BNPL", "Medical Payment Plans"]
BNPL_FREQUENCIES = {
"biweekly": 14,
"monthly": 30,
"4-payment": 14,
}
def simulate_payoff(debts, extra_monthly, method="avalanche"):
import copy
from datetime import date
from dateutil.relativedelta import relativedelta
# Only credit cards and medical debts with a balance
active = [d for d in debts if not d.get("is_bnpl") and d.get("active", True)
and float(d.get("current_balance", 0)) > 0]
if not active:
return [], 0, 0, 0
# Build working copies
working = []
for d in active:
working.append({
"id": d["id"],
"name": d["name"],
"owner": d.get("owner", ""),
"balance": float(d.get("current_balance", 0)),
"apr": float(d.get("apr", 0)),
"minimum": float(d.get("minimum", 0)),
"paid_off_month": None,
"total_interest": 0,
"total_paid": 0,
})
total_minimum = sum(w["minimum"] for w in working)
extra = max(extra_monthly - total_minimum, 0)
current_month = date.today().replace(day=1)
total_interest = 0
months = 0
max_months = 600
while any(w["balance"] > 0 for w in working) and months < max_months:
months += 1
current_month_label = current_month.strftime("%b %Y")
# Sort target debt
unpaid = [w for w in working if w["balance"] > 0]
if method == "avalanche":
unpaid.sort(key=lambda x: -x["apr"])
else:
unpaid.sort(key=lambda x: x["balance"])
# Apply interest and minimums
for w in unpaid:
if w["balance"] <= 0:
continue
monthly_rate = (w["apr"] / 100) / 12
interest = round(w["balance"] * monthly_rate, 2)
w["balance"] = round(w["balance"] + interest, 2)
w["total_interest"] += interest
total_interest += interest
payment = min(w["minimum"], w["balance"])
w["balance"] = round(w["balance"] - payment, 2)
w["total_paid"] += payment
# Apply extra to target
remaining_extra = extra
for w in unpaid:
if w["balance"] <= 0:
continue
extra_payment = min(remaining_extra, w["balance"])
w["balance"] = round(w["balance"] - extra_payment, 2)
w["total_paid"] += extra_payment
remaining_extra -= extra_payment
if remaining_extra <= 0:
break
# Roll over freed minimums as extra for next debt
for w in working:
if w["balance"] <= 0 and w["paid_off_month"] is None:
w["paid_off_month"] = current_month_label
extra += w["minimum"]
from datetime import timedelta
current_month = (current_month.replace(day=28) + timedelta(days=4)).replace(day=1)
return working, round(total_interest, 2), months, round(total_minimum + extra_monthly, 2)
def bnpl_payoff_date(payments_made, total_payments, frequency_days):
from datetime import date, timedelta
today = date.today()
remaining = total_payments - payments_made
if remaining <= 0:
return None, 0
days = remaining * frequency_days
payoff = today + timedelta(days=days)
return payoff.strftime("%b %Y"), remaining
OWNER_COLORS = {
"Bonna": "#D4B8C5",
"Tony": "#A0B8C5",
"Both": "#B8A8D4",
}
def load_debts():
return load_json(DEBTS_FILE, [])
def save_debts(debts):
save_json(DEBTS_FILE, debts)
def load_debt_log():
return load_json(DEBT_LOG_FILE, {})
def save_debt_log(log):
save_json(DEBT_LOG_FILE, log)
def month_key(year, month):
return f"{year}-{month:02d}"
def monthly_interest(balance, apr):
if not apr or apr <= 0:
return 0
return round(balance * (apr / 100) / 12, 2)
def payoff_months(balance, apr, monthly_payment, monthly_charges=0):
if monthly_payment <= 0:
return None
if apr and apr > 0:
monthly_rate = (apr / 100) / 12
else:
monthly_rate = 0
b = balance
months = 0
while b > 0 and months < 600:
b = b + (b * monthly_rate) + monthly_charges - monthly_payment
months += 1
if monthly_charges == 0 and b >= balance:
return None
return months if months < 600 else None
def get_debt_month(debt_id, year, month):
log = load_debt_log()
mk = month_key(year, month)
return log.get(mk, {}).get(debt_id, {})
def update_debt_month(debt_id, year, month, data):
log = load_debt_log()
mk = month_key(year, month)
log.setdefault(mk, {})[debt_id] = data
save_debt_log(log)
def compute_ending_balance(debt, year, month):
mk = month_key(year, month)
log = load_debt_log()
entry = log.get(mk, {}).get(debt["id"], {})
starting = entry.get("starting_balance", debt.get("current_balance", 0))
interest = entry.get("interest") if entry.get("interest") is not None else monthly_interest(starting, debt.get("apr", 0))
new_charges = entry.get("new_charges", 0)
payment = entry.get("payment", 0)
ending = round(float(starting) + float(interest) + float(new_charges) - float(payment), 2)
return max(ending, 0)