188 lines
5.5 KiB
Python
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)
|