Initial commit — Moon Household Budget
This commit is contained in:
194
debts.py
Normal file
194
debts.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
import math
|
||||
|
||||
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():
|
||||
if os.path.exists(DEBTS_FILE):
|
||||
with open(DEBTS_FILE) as f:
|
||||
return json.load(f)
|
||||
return []
|
||||
|
||||
|
||||
def save_debts(debts):
|
||||
with open(DEBTS_FILE, "w") as f:
|
||||
json.dump(debts, f, indent=2)
|
||||
|
||||
|
||||
def load_debt_log():
|
||||
if os.path.exists(DEBT_LOG_FILE):
|
||||
with open(DEBT_LOG_FILE) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_debt_log(log):
|
||||
with open(DEBT_LOG_FILE, "w") as f:
|
||||
json.dump(log, f, indent=2)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user