Files
Moon-Household-Budget/app.py
2026-06-06 14:22:57 -05:00

2862 lines
103 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from flask import Flask, render_template, request, redirect, url_for, jsonify, session
import json
import os
import pdfplumber
import re
from datetime import datetime, date
import calendar as cal_module
from categories import CATEGORIES, CATEGORY_COLORS
from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num
from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed,
months_until, DEFAULT_FUNDS)
from mortgage import (load_mortgage, save_mortgage, monthly_payment,
amortization_schedule, biweekly_schedule,
current_balance_estimate)
from assets import (load_assets, save_assets, car_display_name,
total_spent, flip_profit, flip_total_invested,
ASSET_CATEGORIES)
from debts import (load_debts, save_debts, load_debt_log, save_debt_log,
DEBT_CATEGORIES, OWNER_COLORS, month_key as debt_month_key,
monthly_interest, payoff_months, compute_ending_balance,
update_debt_month, get_debt_month, bnpl_payoff_date,
simulate_payoff)
from income import (load_income_log, save_income_log, income_by_month,
income_ytd, log_income_entry, delete_income_entry,
INCOME_STREAMS, OWNER_COLORS as INCOME_OWNER_COLORS,
month_key as income_month_key)
app = Flask(__name__)
app.secret_key = "fullmoonbakehouse"
import re as _re
from markupsafe import Markup
@app.template_filter('bold_md')
def bold_md(text):
"""Convert **text** markdown to <strong>text</strong>."""
result = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
return Markup(result)
@app.template_filter('money')
def money_filter(value):
"""Format a number as $1,234.56"""
try:
return "{:,.2f}".format(float(value))
except (TypeError, ValueError):
return "0.00"
TAX_FILE = os.path.join(os.path.dirname(__file__), "data", "taxes.json")
def load_taxes():
if os.path.exists(TAX_FILE):
with open(TAX_FILE) as f:
return json.load(f)
return {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []}
def save_taxes(data):
with open(TAX_FILE, "w") as f:
json.dump(data, f, indent=2)
STANDARD_DEDUCTION_MFJ = 30000 # 2025 married filing jointly
def month_status_summary(total_income, total_spending, budgeted_by_cat, spending_by_cat, bills_to_date, total_bills, today, year, month):
"""Generate a plain-English summary of how the month is going."""
import calendar as _cal
_, days_in_month = _cal.monthrange(year, month)
day = today.day if (today.year == year and today.month == month) else days_in_month
pct_through = day / days_in_month
lines = []
# Income check
if total_income == 0:
lines.append("No income logged yet this month.")
else:
lines.append(f"You've brought in **${total_income:,.2f}** so far this month.")
# Spending vs budget
total_budgeted = sum(budgeted_by_cat.values())
if total_budgeted > 0 and total_spending > 0:
spend_pct = total_spending / total_budgeted * 100
if spend_pct < 60:
lines.append(f"Spending is looking good — you've used **{spend_pct:.0f}%** of your category budgets.")
elif spend_pct < 90:
lines.append(f"Spending is on track at **{spend_pct:.0f}%** of budgeted categories.")
else:
over_cats = [c for c in spending_by_cat if budgeted_by_cat.get(c, 0) > 0 and spending_by_cat[c] > budgeted_by_cat[c]]
if over_cats:
lines.append(f"Heads up — you're over budget in **{', '.join(over_cats)}**.")
else:
lines.append(f"You've used **{spend_pct:.0f}%** of your category budgets — keep an eye on spending.")
# Bills
if bills_to_date:
lines.append(f"**{len(bills_to_date)}** bill{'s' if len(bills_to_date) != 1 else ''} due so far this month totaling **${total_bills:,.2f}**.")
# Overall
net = total_income - total_spending - total_bills
if total_income > 0:
if net > 0:
lines.append(f"Overall you're **${net:,.2f} ahead** right now.")
elif net < 0:
lines.append(f"Overall you're **${abs(net):,.2f} in the hole** right now — check what's pulling spending up.")
return lines
def week_status_summary(w):
"""Generate a plain-English summary for a single week."""
lines = []
income = w.get("income_total", 0)
spending = w.get("spending_total", 0)
leftover = w.get("leftover", 0)
budgeted = sum(float(v.get("budgeted", 0) or 0) for v in w.get("cat_budget", {}).values())
actuals = w.get("cat_actuals", {})
over_cats = [c for c, amt in actuals.items() if w["cat_budget"].get(c, {}).get("budgeted") and amt > float(w["cat_budget"][c].get("budgeted", 0))]
if income == 0:
lines.append("No income logged for this week yet.")
else:
lines.append(f"**${income:,.2f}** income received this week.")
if spending > 0 and budgeted > 0:
pct = spending / budgeted * 100
if pct < 70:
lines.append(f"Spending is under control at **{pct:.0f}%** of budget.")
elif pct < 100:
lines.append(f"Spending is at **{pct:.0f}%** of budget — getting close.")
else:
lines.append(f"You've gone over budget this week.")
if over_cats:
lines.append(f"Over in: **{', '.join(over_cats)}**.")
if leftover > 0:
lines.append(f"**${leftover:,.2f}** left over.")
elif leftover < 0:
lines.append(f"You're **${abs(leftover):,.2f} short** this week.")
return lines
def compute_tax_estimate(gross_income, rates, se_income=None):
"""Break down estimated taxes, married filing jointly.
se_income: portion subject to self-employment tax (defaults to all income).
W-2 income = gross_income - se_income, taxed normally but no SE tax.
"""
if se_income is None:
se_income = gross_income
# SE tax is on 92.35% of net self-employment earnings (IRS rule)
se_tax = round(se_income * 0.9235 * (rates["se_tax"] / 100), 2)
# Deduct half of SE tax, then standard deduction, before income tax
taxable = max(gross_income - (se_tax / 2) - STANDARD_DEDUCTION_MFJ, 0)
federal = round(taxable * (rates["federal"] / 100), 2)
wi = round(taxable * (rates["wi_state"] / 100), 2)
total = round(se_tax + federal + wi, 2)
effective_rate = round((total / gross_income * 100), 1) if gross_income else 0
return {"se_tax": se_tax, "federal": federal, "wi_state": wi, "total": total, "effective_rate": effective_rate}
@app.context_processor
def inject_unreviewed_count():
transactions = get_transactions()
count = sum(1 for t in transactions if not t.get("reviewed"))
return dict(unreviewed_count=count)
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
TRANSACTIONS_FILE = os.path.join(DATA_DIR, "transactions.json")
RULES_FILE = os.path.join(DATA_DIR, "rules.json")
BUDGET_FILE = os.path.join(DATA_DIR, "budget.json")
NET_WORTH_FILE = os.path.join(DATA_DIR, "net_worth_accounts.json")
def load_nw_accounts():
if os.path.exists(NET_WORTH_FILE):
with open(NET_WORTH_FILE) as f:
return json.load(f)
return []
def save_nw_accounts(accounts):
with open(NET_WORTH_FILE, "w") as f:
json.dump(accounts, f, indent=2)
def load_json(path, default):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return default
def save_json(path, data):
with open(path, "w") as f:
json.dump(data, f, indent=2)
def get_transactions():
return load_json(TRANSACTIONS_FILE, [])
def save_transactions(transactions):
save_json(TRANSACTIONS_FILE, transactions)
def get_rules():
return load_json(RULES_FILE, {})
def save_rules(rules):
save_json(RULES_FILE, rules)
def get_budget():
return load_json(BUDGET_FILE, {})
def save_budget(budget):
save_json(BUDGET_FILE, budget)
def transaction_category_amounts(t):
"""Returns list of (category, subcategory, amount) for a transaction, respecting splits."""
if t.get("splits"):
return [(s["category"], s.get("subcategory",""), float(s["amount"])) for s in t["splits"]]
return [(t.get("category"), t.get("subcategory",""), float(t.get("amount", 0)))]
def auto_categorize(description, rules):
desc_upper = description.upper()
for keyword, category_info in rules.items():
if keyword.upper() in desc_upper:
return category_info
return None
def parse_pdf_transactions(filepath):
transactions = []
with pdfplumber.open(filepath) as pdf:
full_text = ""
for page in pdf.pages:
text = page.extract_text()
if text:
full_text += text + "\n"
lines = full_text.split("\n")
date_pattern = re.compile(r"(\d{1,2}/\d{1,2}(?:/\d{2,4})?)")
for line in lines:
line = line.strip()
if not line:
continue
date_match = date_pattern.search(line)
if not date_match:
continue
amount_match = re.search(r"\$?([\d,]+\.\d{2})", line)
if not amount_match:
continue
date_str = date_match.group(1)
amount_str = amount_match.group(1).replace(",", "")
date_pos = date_match.start()
amount_pos = amount_match.start()
description = line[date_pos + len(date_match.group(0)):amount_pos].strip()
description = re.sub(r"\s+", " ", description).strip()
if not description:
description = line[:date_pos].strip() or "Unknown"
try:
amount = float(amount_str)
except ValueError:
continue
if amount == 0:
continue
transactions.append({
"id": f"{date_str}_{description[:20]}_{amount}".replace(" ", "_"),
"date": date_str,
"description": description,
"amount": amount,
"category": None,
"subcategory": None,
"account": "",
"notes": "",
"splits": [],
"reviewed": False,
})
return transactions
@app.route("/")
def index():
today = date.today()
year = today.year
month = today.month
mk = month_key(year, month)
# Unreviewed transactions
all_txns = get_transactions()
unreviewed_count = sum(1 for t in all_txns if not t.get("reviewed"))
recent = sorted(
[t for t in all_txns if t.get("reviewed")],
key=lambda t: t.get("date", ""),
reverse=True
)[:8]
# Income
from dateutil import parser as date_parser
log = load_income_log()
month_income_entries = [e for e in log if e.get("month") == mk]
income_by_stream = {}
for e in month_income_entries:
s = e["stream"]
income_by_stream[s] = round(income_by_stream.get(s, 0) + float(e.get("amount", 0)), 2)
total_income = sum(income_by_stream.values())
ytd_by_stream, _ = income_ytd(year)
income_ytd_total = sum(ytd_by_stream.values())
# Use Friday-to-Friday week ranges to define the month's budget window
ranges = week_ranges(year, month)
budget_start = ranges[0][0]
budget_end = ranges[-1][1]
# Spending from categorized transactions within budget window
skip_cats = {"Income", "Debt", "Transfer"}
spending_by_cat = {}
for t in all_txns:
if not t.get("reviewed") or not t.get("category") or t.get("ignored"):
continue
try:
d = date_parser.parse(t["date"]).date()
except Exception:
continue
if budget_start <= d <= budget_end:
for cat, subcat, amt in transaction_category_amounts(t):
if cat and cat not in skip_cats:
spending_by_cat[cat] = round(spending_by_cat.get(cat, 0) + amt, 2)
total_spending = sum(spending_by_cat.values())
# Bills — only count bills due up to today
monthly_bills = bills_for_month(year, month)
bills_to_date = [b for b in monthly_bills if b.get("due_date") and b["due_date"] <= today]
total_bills = sum(float(b.get("amount") or 0) for b in bills_to_date)
# Sinking contributions this month
all_weekly = get_weekly()
month_weekly = all_weekly.get(mk, {})
sinking_contributions = {}
for wk_data in month_weekly.values():
for fund_name, info in wk_data.get("sinking", {}).items():
amt = float(info.get("contributed") or 0)
sinking_contributions[fund_name] = round(sinking_contributions.get(fund_name, 0) + amt, 2)
total_sinking = sum(sinking_contributions.values())
# Budgeted amounts — only sum weeks that have already started (start date <= today)
budgeted_by_cat = {}
for i, (wk_start, wk_end) in enumerate(ranges, 1):
if wk_start > today:
break
wk_data = month_weekly.get(str(i), {})
for cat, info in wk_data.get("categories", {}).items():
b = float(info.get("budgeted") or 0)
budgeted_by_cat[cat] = round(budgeted_by_cat.get(cat, 0) + b, 2)
total_out = total_spending + total_bills + total_sinking
net = round(total_income - total_out, 2)
# Cash accounts for overview
cash_accounts = load_nw_accounts()
total_cash = sum(float(a.get("balance") or 0) for a in cash_accounts)
# Plain-English summary
month_summary = month_status_summary(total_income, total_spending, budgeted_by_cat, spending_by_cat, bills_to_date, total_bills, today, year, month)
# Tax estimate for overview widget
tax_data = load_taxes()
tax_rates = tax_data.get("rates", {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3})
ytd_by_stream_all, _ = income_ytd(year)
ytd_total_all = sum(ytd_by_stream_all.values())
ytd_se_all = sum(v for s, v in ytd_by_stream_all.items() if s != "Auto King")
tax_estimate = compute_tax_estimate(ytd_total_all, tax_rates, se_income=ytd_se_all)
all_funds = load_funds()
tax_fund = next((f for f in all_funds if f["name"] == "Taxes"), None)
tax_set_aside = round(float(tax_fund["balance"]) if tax_fund else 0, 2)
tax_still_needed = round(max(tax_estimate["total"] - tax_set_aside, 0), 2)
return render_template("index.html",
today=today,
year=year,
month=month,
month_name=cal_module.month_name[month],
unreviewed_count=unreviewed_count,
recent=recent,
total_income=total_income,
income_by_stream=income_by_stream,
income_streams=INCOME_STREAMS,
income_ytd_total=income_ytd_total,
total_spending=total_spending,
spending_by_cat=spending_by_cat,
budgeted_by_cat=budgeted_by_cat,
total_bills=total_bills,
monthly_bills=bills_to_date,
total_sinking=total_sinking,
sinking_contributions=sinking_contributions,
total_out=total_out,
net=net,
tax_estimate=tax_estimate,
tax_set_aside=tax_set_aside,
tax_still_needed=tax_still_needed,
month_summary=month_summary,
cash_accounts=cash_accounts,
total_cash=total_cash,
colors=CATEGORY_COLORS,
categories=CATEGORIES,
owner_colors=INCOME_OWNER_COLORS,
)
@app.route("/import", methods=["GET", "POST"])
def import_transactions():
from flask import flash
if request.method == "POST":
file = request.files.get("statement")
account = request.form.get("account", "")
if not file or not file.filename:
flash("No file selected.", "error")
return redirect("/transactions")
filename = file.filename.lower()
upload_path = os.path.join(DATA_DIR, file.filename)
file.save(upload_path)
if filename.endswith(".pdf"):
new_txns = parse_pdf_transactions(upload_path)
elif filename.endswith(".csv"):
new_txns = parse_csv_transactions(upload_path)
else:
flash("Please upload a PDF or CSV file.", "error")
return redirect("/transactions")
for t in new_txns:
t["account"] = account
rules = get_rules()
for t in new_txns:
match = auto_categorize(t["description"], rules)
if match:
t["category"] = match.get("category")
t["subcategory"] = match.get("subcategory")
existing = get_transactions()
existing_ids = {t["id"] for t in existing}
added = 0
for t in new_txns:
if t["id"] not in existing_ids:
existing.append(t)
added += 1
save_transactions(existing)
flash(f"Imported {added} new transactions from {file.filename}.", "success")
redirect_to = request.form.get("redirect_to", "")
if redirect_to == "weekly":
return redirect(url_for("weekly_overview"))
return redirect("/transactions")
return render_template("import.html", message=None)
def parse_csv_transactions(filepath):
import csv
transactions = []
with open(filepath, newline="", encoding="utf-8-sig") as f:
# Peek at first line to detect WestConsin format (starts with "Account Name")
first_line = f.readline()
f.seek(0)
is_westconsin = first_line.startswith("Account Name")
if is_westconsin:
# Skip the 3 metadata rows, then read with DictReader
for _ in range(3):
f.readline()
reader = csv.DictReader(f)
for row in reader:
date_val = row.get("Date", "").strip()
# Memo has the real merchant name; fall back to Description
memo = row.get("Memo", "").strip()
desc_val = row.get("Description", "").strip()
# Pull the merchant name from the memo (everything before the 4-digit MCC code)
import re
merchant_match = re.match(r"^(.+?)\s+\d{4}\s+", memo)
description = merchant_match.group(1).strip() if merchant_match else (memo or desc_val)
debit = row.get("Amount Debit", "").strip()
credit = row.get("Amount Credit", "").strip()
# Debits are negative in the file; credits are positive
try:
if debit:
amount = abs(float(debit))
txn_type = "debit"
elif credit:
amount = float(credit)
txn_type = "credit"
else:
continue
except ValueError:
continue
if amount == 0:
continue
txn_id = row.get("Transaction Number", "").strip() or \
f"{date_val}_{description[:20]}_{amount}".replace(" ", "_")
transactions.append({
"id": txn_id,
"date": date_val,
"description": description,
"amount": amount,
"txn_type": txn_type,
"category": None,
"subcategory": None,
"account": "",
"notes": "",
"splits": [],
"reviewed": False,
})
else:
reader = csv.DictReader(f)
for row in reader:
date_val = row.get("Date") or row.get("date") or row.get("Transaction Date") or ""
desc_val = row.get("Description") or row.get("description") or row.get("Merchant") or row.get("Name") or ""
amount_val = row.get("Amount") or row.get("amount") or row.get("Debit") or ""
amount_str = str(amount_val).replace("$", "").replace(",", "").strip().lstrip("-")
try:
amount = float(amount_str)
except ValueError:
continue
if amount == 0:
continue
transactions.append({
"id": f"{date_val}_{desc_val[:20]}_{amount}".replace(" ", "_"),
"date": date_val,
"description": desc_val,
"amount": amount,
"category": None,
"subcategory": None,
"account": "",
"notes": "",
"splits": [],
"reviewed": False,
})
return transactions
@app.route("/review")
def review():
transactions = get_transactions()
unreviewed = [t for t in transactions if not t.get("reviewed")]
if not unreviewed:
return render_template("review.html", transaction=None, categories=CATEGORIES, colors=CATEGORY_COLORS)
current = unreviewed[0]
rules = get_rules()
suggestion = auto_categorize(current["description"], rules)
assets = load_assets()
personal_cars = [
{**c, "display_name": car_display_name(c)}
for c in assets.get("personal", [])
]
# Detect BNPL transactions and find matching loans
BNPL_KEYWORDS = ["klarna", "afterpay", "affirm", "sezzle", "zip", "paidy"]
desc_lower = current["description"].lower()
bnpl_lender = next((k for k in BNPL_KEYWORDS if k in desc_lower), None)
bnpl_matches = []
if bnpl_lender:
all_debts = load_debts()
for d in all_debts:
if not d.get("is_bnpl") or not d.get("active", True):
continue
provider = d.get("bnpl_provider", "").lower()
if bnpl_lender in provider or provider in bnpl_lender:
amt = float(d.get("payment_amount") or 0)
txn_amt = float(current.get("amount") or 0)
bnpl_matches.append({
"id": d["id"],
"name": d["name"],
"payment_amount": amt,
"amount_match": abs(amt - txn_amt) < 0.02,
"payments_made": d.get("payments_made", 0),
"total_payments": d.get("total_payments", 0),
})
# Sort exact amount matches to top
bnpl_matches.sort(key=lambda x: (not x["amount_match"], x["name"]))
sinking_funds = load_funds()
return render_template("review.html",
transaction=current,
suggestion=suggestion,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
remaining=len(unreviewed),
personal_cars=personal_cars,
bnpl_lender=bnpl_lender,
bnpl_matches=bnpl_matches,
sinking_funds=sinking_funds,
)
@app.route("/categorize", methods=["POST"])
def categorize():
txn_id = request.form.get("id")
category = request.form.get("category")
subcategory = request.form.get("subcategory", "")
notes = request.form.get("notes", "")
remember = request.form.get("remember") == "on"
car_id = request.form.get("car_id", "")
transactions = get_transactions()
txn = None
for t in transactions:
if t["id"] == txn_id:
t["category"] = category
t["subcategory"] = subcategory
t["notes"] = notes
t["reviewed"] = True
if car_id:
t["car_id"] = car_id
if remember and t["description"]:
rules = get_rules()
keyword = t["description"].split()[0] if t["description"] else t["description"]
rules[keyword] = {"category": category, "subcategory": subcategory}
save_rules(rules)
txn = t
break
save_transactions(transactions)
# Auto-contribute to sinking fund when categorized as Transfer > Sinking Fund
if category == "Transfer" and subcategory == "Sinking Fund" and txn:
fund_name = request.form.get("sinking_fund_name", "").strip()
if fund_name:
funds = load_funds()
today_str = txn.get("date", str(date.today()))
for f in funds:
if f["name"] == fund_name:
f["balance"] = round(float(f.get("balance", 0)) + float(txn.get("amount", 0)), 2)
break
save_funds(funds)
# Auto-log gas to car profile
if car_id and category == "Car" and subcategory == "Gas" and txn:
import uuid
assets = load_assets()
for car in assets.get("personal", []):
if car["id"] == car_id:
car.setdefault("gas_log", []).append({
"id": str(uuid.uuid4()),
"date": txn.get("date", ""),
"amount": txn.get("amount", 0),
"notes": f"Auto-logged from: {txn.get('description', '')}",
"gallons": "",
})
break
save_assets(assets)
return redirect(url_for("review"))
@app.route("/split", methods=["POST"])
def split_transaction():
txn_id = request.form.get("id")
transactions = get_transactions()
txn = next((t for t in transactions if t["id"] == txn_id), None)
if not txn:
return redirect(url_for("review"))
splits = []
i = 0
while True:
cat = request.form.get(f"split_cat_{i}")
if cat is None:
break
amt = request.form.get(f"split_amt_{i}", "")
subcat = request.form.get(f"split_subcat_{i}", "")
note = request.form.get(f"split_note_{i}", "")
try:
amount = float(amt)
except ValueError:
i += 1
continue
if amount > 0 and cat:
splits.append({"category": cat, "subcategory": subcat, "amount": amount, "note": note})
i += 1
redirect_to = request.form.get("redirect", "review")
if splits:
txn["splits"] = splits
txn["category"] = splits[0]["category"]
txn["subcategory"] = splits[0].get("subcategory", "")
txn["reviewed"] = True
save_transactions(transactions)
if redirect_to == "transactions":
return redirect("/transactions")
return redirect(url_for("review"))
@app.route("/skip", methods=["POST"])
def skip():
txn_id = request.form.get("id")
transactions = get_transactions()
for t in transactions:
if t["id"] == txn_id:
t["reviewed"] = True
break
save_transactions(transactions)
return redirect(url_for("review"))
@app.route("/bulk-ignore-before", methods=["POST"])
def bulk_ignore_before():
from dateutil import parser as date_parser
before_date_str = request.form.get("before_date")
try:
before_date = date_parser.parse(before_date_str).date()
except Exception:
return redirect(url_for("review"))
transactions = get_transactions()
count = 0
for t in transactions:
if t.get("reviewed"):
continue
try:
d = date_parser.parse(t["date"]).date()
except Exception:
continue
if d < before_date:
t["reviewed"] = True
t["ignored"] = True
t["category"] = "Ignored"
count += 1
save_transactions(transactions)
return redirect(url_for("review"))
@app.route("/ignore", methods=["POST"])
def ignore_transaction():
txn_id = request.form.get("id")
label = request.form.get("label", "Ignored")
redirect_to = request.form.get("redirect", "review")
transactions = get_transactions()
for t in transactions:
if t["id"] == txn_id:
t["reviewed"] = True
t["ignored"] = True
t["category"] = label
break
save_transactions(transactions)
if redirect_to == "transactions":
return redirect("/transactions")
return redirect(url_for("review"))
@app.route("/transactions")
def transactions_view():
from dateutil import parser as date_parser
today = date.today()
year = today.year
month = today.month
mk = month_key(year, month)
transactions = get_transactions()
reviewed = sorted(
[t for t in transactions if t.get("reviewed")],
key=lambda t: t.get("date", ""),
reverse=True,
)
# Find current week range
skip_cats = {"Income", "Debt", "Transfer"}
ranges = week_ranges(year, month)
current_week_num = 1
week_start = ranges[0][0]
week_end = ranges[0][1]
for i, (s, e) in enumerate(ranges, 1):
if s <= today <= e:
current_week_num = i
week_start = s
week_end = e
break
# Spending just for this week
spending_by_cat = {}
for t in transactions:
if not t.get("reviewed") or not t.get("category") or t.get("ignored"):
continue
try:
d = date_parser.parse(t["date"]).date()
except Exception:
continue
if week_start <= d <= week_end:
for cat, subcat, amt in transaction_category_amounts(t):
if cat and cat not in skip_cats:
spending_by_cat[cat] = round(spending_by_cat.get(cat, 0) + amt, 2)
# Budget for this week only
all_weekly = get_weekly()
month_weekly = all_weekly.get(mk, {})
wk_data = month_weekly.get(str(current_week_num), {})
budgeted_by_cat = {}
for cat, info in wk_data.get("categories", {}).items():
b = float(info.get("budgeted") or 0)
if b:
budgeted_by_cat[cat] = b
spending_by_cat = dict(sorted(spending_by_cat.items(), key=lambda x: x[1], reverse=True))
week_label = f"Week {current_week_num} ({week_start.strftime('%b %-d')} {week_end.strftime('%b %-d')})"
# Optional filters from query string
filter_from = request.args.get("from_date", "")
filter_to = request.args.get("to_date", "")
filter_cat = request.args.get("category", "")
filter_search = request.args.get("search", "").strip()
if filter_from:
try:
from_d = date_parser.parse(filter_from).date()
reviewed = [t for t in reviewed if date_parser.parse(t["date"]).date() >= from_d]
except Exception:
pass
if filter_to:
try:
to_d = date_parser.parse(filter_to).date()
reviewed = [t for t in reviewed if date_parser.parse(t["date"]).date() <= to_d]
except Exception:
pass
if filter_cat:
reviewed = [t for t in reviewed if t.get("category") == filter_cat]
if filter_search:
q = filter_search.lower()
reviewed = [t for t in reviewed if
q in t.get("description", "").lower() or
q in t.get("notes", "").lower()]
import calendar as cal_mod
return render_template("transactions.html",
transactions=reviewed,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
filter_from=filter_from,
filter_to=filter_to,
filter_cat=filter_cat,
filter_search=filter_search,
spending_by_cat=spending_by_cat,
budgeted_by_cat=budgeted_by_cat,
week_label=week_label,
month_name=cal_mod.month_name[month],
year=year, month=month,
)
@app.route("/edit/<txn_id>", methods=["GET", "POST"])
def edit_transaction(txn_id):
transactions = get_transactions()
txn = next((t for t in transactions if t["id"] == txn_id), None)
if not txn:
return redirect("/transactions")
if request.method == "POST":
txn["category"] = request.form.get("category")
txn["subcategory"] = request.form.get("subcategory", "")
txn["notes"] = request.form.get("notes", "")
# Clear ignored flag when manually re-categorized
txn.pop("ignored", None)
save_transactions(transactions)
return redirect("/transactions")
return render_template("edit.html", transaction=txn, categories=CATEGORIES, colors=CATEGORY_COLORS, sinking_funds=load_funds())
@app.route("/summary")
def summary():
transactions = get_transactions()
reviewed = [t for t in transactions if t.get("reviewed") and t.get("category")]
by_category = {}
for t in reviewed:
cat = t["category"]
subcat = t.get("subcategory") or "General"
if cat not in by_category:
by_category[cat] = {"total": 0, "subcategories": {}}
by_category[cat]["total"] += t["amount"]
by_category[cat]["subcategories"][subcat] = by_category[cat]["subcategories"].get(subcat, 0) + t["amount"]
# Sort by total descending
by_category = dict(sorted(by_category.items(), key=lambda x: x[1]["total"], reverse=True))
for cat in by_category:
by_category[cat]["subcategories"] = dict(
sorted(by_category[cat]["subcategories"].items(), key=lambda x: x[1], reverse=True)
)
return render_template("summary.html",
by_category=by_category,
colors=CATEGORY_COLORS,
categories=CATEGORIES,
)
WEEKLY_FILE = os.path.join(DATA_DIR, "weekly.json")
def get_weekly():
return load_json(WEEKLY_FILE, {})
def save_weekly(data):
save_json(WEEKLY_FILE, data)
def month_key(year, month):
return f"{year}-{month:02d}"
def week_ranges(year, month):
from datetime import date, timedelta
# Find the Friday on or before the 1st of the month
first = date(year, month, 1)
days_since_friday = (first.weekday() - 4) % 7 # 4 = Friday
start = first - timedelta(days=days_since_friday)
# Build Friday-to-Thursday weeks until we've passed the end of the month
import calendar as _cal
_, num_days = _cal.monthrange(year, month)
month_end = date(year, month, num_days)
weeks = []
while start <= month_end:
end = start + timedelta(days=6)
weeks.append((start, end))
start = start + timedelta(days=7)
return weeks
@app.route("/calendar")
@app.route("/calendar/<int:year>/<int:month>")
def bill_calendar(year=None, month=None):
today = date.today()
year = year or today.year
month = month or today.month
grid = calendar_grid(year, month)
monthly_bills = bills_for_month(year, month)
bills_by_day = {}
for b in monthly_bills:
d = b["due_day"]
bills_by_day.setdefault(d, []).append(b)
prev_month = month - 1 or 12
prev_year = year if month > 1 else year - 1
next_month = month % 12 + 1
next_year = year if month < 12 else year + 1
month_name = cal_module.month_name[month]
return render_template("calendar.html",
grid=grid,
bills_by_day=bills_by_day,
year=year,
month=month,
month_name=month_name,
prev_year=prev_year,
prev_month=prev_month,
next_year=next_year,
next_month=next_month,
colors=CATEGORY_COLORS,
categories=CATEGORIES,
today=today,
)
@app.route("/bills")
def bills_list():
all_bills = load_bills()
# Only show recurring bills (have due_day), not BNPL payment entries
bills = [b for b in all_bills if "due_day" in b]
return render_template("bills.html",
bills=bills,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
)
@app.route("/bills/add", methods=["POST"])
def bill_add():
bills = load_bills()
import uuid
bill = {
"id": str(uuid.uuid4()),
"name": request.form.get("name", "").strip(),
"amount": float(request.form.get("amount") or 0),
"due_day": int(request.form.get("due_day") or 1),
"category": request.form.get("category", ""),
"subcategory": request.form.get("subcategory", ""),
"active": True,
"autopay": request.form.get("autopay") == "on",
"remind": request.form.get("autopay") != "on",
"notes": request.form.get("notes", ""),
}
bills.append(bill)
save_bills(bills)
return redirect(url_for("bills_list"))
@app.route("/bills/toggle/<bill_id>/<field>", methods=["POST"])
def bill_toggle(bill_id, field):
if field not in ("autopay", "remind"):
return jsonify({"ok": False}), 400
bills = load_bills()
for b in bills:
if b["id"] == bill_id:
b[field] = not b.get(field, False)
if field == "autopay":
b["remind"] = not b["autopay"]
save_bills(bills)
return jsonify({"ok": True})
@app.route("/reminders/run", methods=["POST"])
def run_reminders():
from reminders import check_and_send_reminders
try:
results = check_and_send_reminders()
msg = "Reminders sent: " + ", ".join(results) if results else "No reminders due today."
except Exception as e:
msg = f"Error: {e}"
return jsonify({"message": msg})
@app.route("/bills/delete/<bill_id>", methods=["POST"])
def bill_delete(bill_id):
bills = [b for b in load_bills() if b["id"] != bill_id]
save_bills(bills)
return redirect(url_for("bills_list"))
@app.route("/bills/edit/<bill_id>", methods=["POST"])
def bill_edit(bill_id):
bills = load_bills()
for b in bills:
if b["id"] == bill_id:
b["name"] = request.form.get("name", b["name"]).strip()
b["amount"] = float(request.form.get("amount") or b["amount"])
b["due_day"] = int(request.form.get("due_day") or b["due_day"])
b["category"] = request.form.get("category", b["category"])
b["subcategory"] = request.form.get("subcategory", b.get("subcategory", ""))
b["notes"] = request.form.get("notes", b.get("notes", ""))
break
save_bills(bills)
return redirect(url_for("bills_list"))
@app.route("/weekly")
@app.route("/weekly/<int:year>/<int:month>")
def weekly_overview(year=None, month=None):
today = date.today()
year = year or today.year
month = month or today.month
mk = month_key(year, month)
all_weekly = get_weekly()
month_data = all_weekly.get(mk, {})
ranges = week_ranges(year, month)
monthly_bills = bills_for_month(year, month)
# Pre-process transactions: parse dates and index reviewed ones by date
from dateutil import parser as date_parser
all_transactions = get_transactions()
reviewed_txns = [t for t in all_transactions if t.get("reviewed") and t.get("category") and not t.get("ignored")]
def parse_txn_date(t):
try:
return date_parser.parse(t["date"]).date()
except Exception:
return None
txns_with_dates = [(parse_txn_date(t), t) for t in reviewed_txns]
txns_with_dates = [(d, t) for d, t in txns_with_dates if d]
weeks = []
for i, (start, end) in enumerate(ranges, 1):
wk = str(i)
wdata = month_data.get(wk, {})
# Match bills whose due day falls within this week's date range
week_bills = []
for b in monthly_bills:
due_day = b.get("due_day")
if not due_day:
continue
import calendar as _cal
_, nm = _cal.monthrange(year, month)
try:
bill_date = date(year, month, min(due_day, nm))
if start <= bill_date <= end:
week_bills.append(b)
except Exception:
pass
bills_total = sum(float(b.get("amount") or 0) for b in week_bills)
income_sources = ["Auto King", "Studio", "Bakery", "Misc"]
income = wdata.get("income", {})
cat_budget = wdata.get("categories", {})
sinking = wdata.get("sinking", {})
extra_debt = wdata.get("extra_debt", {})
# Sum categorized transactions falling in this week's date range
# Skip Income (that's tracked separately) and Transfer (moves between accounts, not real spending)
# Include Debt — credit card payments etc. are real outflows from checking
skip_cats = {"Income", "Transfer"}
cat_actuals = {}
for txn_date, t in txns_with_dates:
if start <= txn_date <= end:
for cat, subcat, amt in transaction_category_amounts(t):
if cat and cat not in skip_cats:
cat_actuals[cat] = round(cat_actuals.get(cat, 0) + amt, 2)
income_total = sum(float(v.get("actual") or 0) for v in income.values())
spending_total = sum(cat_actuals.values())
sinking_total = sum(float(v.get("contributed") or 0) for v in sinking.values())
extra_debt_total = sum(float(v.get("extra") or 0) for v in extra_debt.values())
starting_balance = float(wdata.get("starting_balance", {}).get("amount", {}).get("value", 0) or 0)
leftover = starting_balance + income_total - spending_total - sinking_total - extra_debt_total
# Format date range label e.g. "May 29 Jun 4"
start_label = start.strftime("%b %-d")
end_label = end.strftime("%b %-d")
# Transactions for this week (reviewed, not ignored)
week_txns = [t for d, t in txns_with_dates if start <= d <= end]
week_obj = {
"num": i,
"start": start,
"end": end,
"start_label": start_label,
"end_label": end_label,
"bills": week_bills,
"bills_total": bills_total,
"income": income,
"income_sources": income_sources,
"income_total": income_total,
"spending_total": spending_total,
"sinking_total": sinking_total,
"extra_debt_total": extra_debt_total,
"starting_balance": starting_balance,
"cat_budget": cat_budget,
"cat_actuals": cat_actuals,
"sinking": sinking,
"extra_debt": extra_debt,
"leftover": leftover,
"transactions": week_txns,
}
week_obj["summary"] = week_status_summary(week_obj)
weeks.append(week_obj)
prev_month = month - 1 or 12
prev_year = year if month > 1 else year - 1
next_month = month % 12 + 1
next_year = year if month < 12 else year + 1
# Figure out which week contains today
current_week = 1
if year == today.year and month == today.month:
for i, (wk_start, wk_end) in enumerate(ranges, 1):
if wk_start <= today <= wk_end:
current_week = i
break
funds = load_funds()
all_debts = load_debts()
active_debts = [d for d in all_debts if d.get("active", True)
and not d.get("is_bnpl")
and float(d.get("current_balance", 0)) > 0]
return render_template("weekly.html",
weeks=weeks,
year=year,
month=month,
month_name=cal_module.month_name[month],
prev_year=prev_year,
prev_month=prev_month,
next_year=next_year,
next_month=next_month,
colors=CATEGORY_COLORS,
categories=CATEGORIES,
sinking_funds=funds,
fund_colors=FUND_COLORS,
active_debts=active_debts,
owner_colors=OWNER_COLORS,
current_week=current_week,
)
@app.route("/weekly/extra-debt", methods=["POST"])
def weekly_extra_debt():
debt_id = request.form.get("debt_id")
amount = float(request.form.get("amount") or 0)
year = int(request.form.get("year"))
month = int(request.form.get("month"))
week = request.form.get("week")
mk = month_key(year, month)
all_weekly = get_weekly()
prev = float(all_weekly.get(mk, {}).get(week, {}).get("extra_debt", {}).get(debt_id, {}).get("extra") or 0)
all_weekly.setdefault(mk, {}).setdefault(week, {}).setdefault("extra_debt", {})[debt_id] = {"extra": amount}
save_weekly(all_weekly)
# Apply extra payment to debt balance
debts = load_debts()
for d in debts:
if d["id"] == debt_id and not d.get("is_bnpl"):
d["current_balance"] = max(round(float(d.get("current_balance", 0)) - amount + prev, 2), 0)
break
save_debts(debts)
return jsonify({"ok": True})
@app.route("/weekly/save", methods=["POST"])
def weekly_save():
year = int(request.form.get("year"))
month = int(request.form.get("month"))
week = request.form.get("week")
field = request.form.get("field")
key = request.form.get("key")
subkey = request.form.get("subkey", "actual")
value = request.form.get("value", "")
mk = month_key(year, month)
all_weekly = get_weekly()
all_weekly.setdefault(mk, {}).setdefault(week, {}).setdefault(field, {}).setdefault(key, {})[subkey] = value
# Sync actual income entries to income_log.json
if field == "income" and subkey == "actual":
amount = float(value) if value else 0
# Remove previous entry for this stream+week if it exists
prev_entry_id = all_weekly[mk][week][field][key].get("_income_log_id")
if prev_entry_id:
delete_income_entry(prev_entry_id)
# Log new entry and store its ID so we can update it later
if amount > 0:
import uuid
entry_id = str(uuid.uuid4())
log_income_entry(key, amount, mk, note=f"Week {week}", entry_id=entry_id)
all_weekly[mk][week][field][key]["_income_log_id"] = entry_id
else:
all_weekly[mk][week][field][key].pop("_income_log_id", None)
save_weekly(all_weekly)
return jsonify({"ok": True})
@app.route("/debts")
@app.route("/debts/<int:year>/<int:month>")
def debts_view(year=None, month=None):
today = date.today()
year = year or today.year
month = month or today.month
mk = debt_month_key(year, month)
debts = load_debts()
log = load_debt_log()
month_log = log.get(mk, {})
import calendar as cal_mod
prev_month = month - 1 or 12
prev_year = year if month > 1 else year - 1
next_month = month % 12 + 1
next_year = year if month < 12 else year + 1
by_category = {cat: [] for cat in DEBT_CATEGORIES}
totals = {cat: {"balance": 0, "minimum": 0, "paid": 0, "extra": 0} for cat in DEBT_CATEGORIES}
grand = {"balance": 0, "minimum": 0, "paid": 0, "extra": 0}
for debt in debts:
if not debt.get("active", True):
continue
entry = month_log.get(debt["id"], {})
starting = float(entry.get("starting_balance") or debt.get("current_balance", 0))
interest = float(entry.get("interest") if entry.get("interest") is not None
else monthly_interest(starting, debt.get("apr", 0)))
new_charges = float(entry.get("new_charges", 0))
payment = float(entry.get("payment", 0))
minimum = float(debt.get("minimum", 0))
extra = max(round(payment - minimum, 2), 0)
ending = max(round(starting + interest + new_charges - payment, 2), 0)
months_left = None
payoff_str = None
if payment > 0 or minimum > 0:
pay = payment if payment > 0 else minimum
m = payoff_months(starting, debt.get("apr", 0), pay)
if m:
months_left = m
future = date(year + (month + m - 1) // 12, ((month + m - 1) % 12) + 1, 1)
payoff_str = future.strftime("%b %Y")
row = {
**debt,
"starting": starting,
"interest": round(interest, 2),
"new_charges": new_charges,
"payment": payment,
"minimum": minimum,
"extra": extra,
"ending": ending,
"months_left": months_left,
"payoff_str": payoff_str,
"entry": entry,
}
cat = debt.get("debt_category", "Credit Cards")
if cat in by_category:
by_category[cat].append(row)
totals[cat]["balance"] += ending
totals[cat]["minimum"] += minimum
totals[cat]["paid"] += payment
totals[cat]["extra"] += extra
grand["balance"] += ending
grand["minimum"] += minimum
grand["paid"] += payment
grand["extra"] += extra
monthly_income_by_stream, _ = income_by_month(year, month)
monthly_income_total = sum(monthly_income_by_stream.values())
dti = round((grand["minimum"] / monthly_income_total * 100), 1) if monthly_income_total > 0 else None
return render_template("debts.html",
by_category=by_category,
totals=totals,
grand=grand,
debt_categories=DEBT_CATEGORIES,
owner_colors=OWNER_COLORS,
year=year,
month=month,
month_name=cal_mod.month_name[month],
prev_year=prev_year,
prev_month=prev_month,
next_year=next_year,
next_month=next_month,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
bnpl_payoff_date=bnpl_payoff_date,
monthly_income=monthly_income_total,
dti=dti,
)
@app.route("/debts/add", methods=["POST"])
def debt_add():
import uuid
debts = load_debts()
debt = {
"id": str(uuid.uuid4()),
"name": request.form.get("name", "").strip(),
"debt_category": request.form.get("debt_category", "Credit Cards"),
"owner": request.form.get("owner", "Bonna"),
"current_balance": float(request.form.get("current_balance") or 0),
"apr": float(request.form.get("apr") or 0),
"minimum": float(request.form.get("minimum") or 0),
"account_match": request.form.get("account_match", "").strip(),
"notes": request.form.get("notes", "").strip(),
"active": True,
}
debts.append(debt)
save_debts(debts)
return redirect(url_for("debts_view"))
@app.route("/debts/add-bnpl", methods=["POST"])
def bnpl_add():
import uuid
from datetime import timedelta
debts = load_debts()
freq = request.form.get("frequency", "biweekly")
freq_days = {"biweekly": 14, "monthly": 30, "4-payment": 14}.get(freq, 14)
total_payments = int(request.form.get("total_payments") or 1)
payments_made = int(request.form.get("payments_made") or 0)
payment_amount = float(request.form.get("payment_amount") or 0)
remaining = total_payments - payments_made
debt_id = str(uuid.uuid4())
# Parse next payment date
next_payment_str = request.form.get("next_payment_date", "").strip()
next_payment_date = None
if next_payment_str:
try:
next_payment_date = datetime.strptime(next_payment_str, "%Y-%m-%d").date()
except ValueError:
pass
description = request.form.get("description", "").strip()
provider = request.form.get("bnpl_provider", "Affirm")
merchant = request.form.get("merchant", "").strip()
bill_name = f"{provider} {description}" + (f" ({merchant})" if merchant else "")
debt = {
"id": debt_id,
"name": description,
"merchant": merchant,
"debt_category": "BNPL",
"bnpl_provider": provider,
"owner": request.form.get("owner", "Bonna"),
"original_amount": float(request.form.get("original_amount") or 0),
"payment_amount": payment_amount,
"total_payments": total_payments,
"payments_made": payments_made,
"frequency": freq,
"frequency_days": freq_days,
"current_balance": round(remaining * payment_amount, 2),
"apr": 0,
"minimum": payment_amount,
"notes": request.form.get("notes", "").strip(),
"active": True,
"is_bnpl": True,
"next_payment_date": next_payment_str,
}
debts.append(debt)
save_debts(debts)
# Auto-generate bill entries for each remaining payment
if next_payment_date and remaining > 0:
bills = load_bills()
for i in range(remaining):
if freq == "monthly":
# advance by month
m = next_payment_date.month + i
y = next_payment_date.year + (m - 1) // 12
m = ((m - 1) % 12) + 1
import calendar as _cal
last_day = _cal.monthrange(y, m)[1]
pay_date = date(y, m, min(next_payment_date.day, last_day))
else:
pay_date = next_payment_date + timedelta(days=freq_days * i)
bills.append({
"id": str(uuid.uuid4()),
"name": bill_name,
"due_date_str": pay_date.strftime("%Y-%m-%d"),
"amount": payment_amount,
"category": "Debt",
"active": True,
"paid": False,
"bnpl_debt_id": debt_id,
"bnpl_payment_num": payments_made + i + 1,
})
save_bills(bills)
redirect_to = request.form.get("redirect_to", "")
if redirect_to == "review":
return redirect(url_for("review"))
return redirect(url_for("debts_view"))
@app.route("/debts/bnpl-edit/<debt_id>", methods=["POST"])
def bnpl_edit(debt_id):
import uuid
from datetime import timedelta
debts = load_debts()
for d in debts:
if d["id"] == debt_id and d.get("is_bnpl"):
freq = request.form.get("frequency", d.get("frequency", "biweekly"))
freq_days = {"biweekly": 14, "monthly": 30, "4-payment": 14}.get(freq, 14)
total_payments = int(request.form.get("total_payments") or d.get("total_payments", 1))
payments_made = int(request.form.get("payments_made") or 0)
payment_amount = float(request.form.get("payment_amount") or d.get("payment_amount", 0))
remaining = total_payments - payments_made
description = request.form.get("description", d.get("name", "")).strip()
provider = request.form.get("bnpl_provider", d.get("bnpl_provider", "Affirm"))
merchant = request.form.get("merchant", d.get("merchant", "")).strip()
next_payment_str = request.form.get("next_payment_date", "").strip()
d["name"] = description
d["merchant"] = merchant
d["bnpl_provider"] = provider
d["owner"] = request.form.get("owner", d.get("owner", "Bonna"))
d["original_amount"] = float(request.form.get("original_amount") or d.get("original_amount", 0))
d["payment_amount"] = payment_amount
d["total_payments"] = total_payments
d["payments_made"] = payments_made
d["frequency"] = freq
d["frequency_days"] = freq_days
d["current_balance"] = max(round(remaining * payment_amount, 2), 0)
d["minimum"] = payment_amount
d["notes"] = request.form.get("notes", "").strip()
d["next_payment_date"] = next_payment_str
d["active"] = remaining > 0
break
save_debts(debts)
# Remove unpaid calendar bills for this plan and regenerate
bills = load_bills()
bills = [b for b in bills if not (b.get("bnpl_debt_id") == debt_id and not b.get("paid"))]
if next_payment_str and remaining > 0:
try:
next_payment_date = datetime.strptime(next_payment_str, "%Y-%m-%d").date()
except ValueError:
next_payment_date = None
if next_payment_date:
bill_name = f"{provider} {description}" + (f" ({merchant})" if merchant else "")
for i in range(remaining):
if freq == "monthly":
m = next_payment_date.month + i
y = next_payment_date.year + (m - 1) // 12
m = ((m - 1) % 12) + 1
import calendar as _cal
last_day = _cal.monthrange(y, m)[1]
pay_date = date(y, m, min(next_payment_date.day, last_day))
else:
pay_date = next_payment_date + timedelta(days=freq_days * i)
bills.append({
"id": str(uuid.uuid4()),
"name": bill_name,
"due_date_str": pay_date.strftime("%Y-%m-%d"),
"amount": payment_amount,
"category": "Debt",
"active": True,
"paid": False,
"bnpl_debt_id": debt_id,
"bnpl_payment_num": payments_made + i + 1,
})
save_bills(bills)
return redirect(url_for("debts_view"))
@app.route("/debts/bnpl-payment/<debt_id>", methods=["POST"])
def bnpl_payment(debt_id):
debts = load_debts()
for d in debts:
if d["id"] == debt_id and d.get("is_bnpl"):
next_num = d.get("payments_made", 0) + 1
d["payments_made"] = next_num
remaining = d["total_payments"] - d["payments_made"]
d["current_balance"] = max(round(remaining * d["payment_amount"], 2), 0)
if d["payments_made"] >= d["total_payments"]:
d["active"] = False
break
save_debts(debts)
# Mark the corresponding bill as paid
bills = load_bills()
for b in bills:
if b.get("bnpl_debt_id") == debt_id and b.get("bnpl_payment_num") == next_num and not b.get("paid"):
b["paid"] = True
break
save_bills(bills)
return redirect(url_for("debts_view"))
@app.route("/categorize/sinking-transfer", methods=["POST"])
def categorize_sinking_transfer():
"""Categorize a transaction as Transfer > Sinking Fund and auto-contribute to the chosen fund."""
txn_id = request.form.get("id")
fund_name = request.form.get("sinking_fund_name", "").strip()
redirect_to = request.form.get("redirect", "transactions")
# Categorize the transaction and find its date/amount
transactions = get_transactions()
txn_amount = 0
txn_date = None
for t in transactions:
if t["id"] == txn_id:
t["category"] = "Transfer"
t["subcategory"] = "Sinking Fund"
t["notes"] = fund_name
t["reviewed"] = True
t.pop("ignored", None)
txn_amount = float(t.get("amount", 0))
txn_date = t.get("date") # e.g. "2026-06-04"
break
save_transactions(transactions)
if fund_name and txn_amount:
# Figure out which week/month this transaction belongs to
txn_week = None
txn_year, txn_month = None, None
if txn_date:
try:
d = date.fromisoformat(txn_date)
txn_year = d.year
txn_month = d.month
txn_week = f"week{get_week_num(d)}"
except Exception:
pass
funds = load_funds()
for f in funds:
if f["name"] == fund_name:
if txn_year and txn_week:
# Use the same dedup logic as the manual weekly entry:
# subtract any previous contribution logged for this fund/week,
# then add the new amount — so if someone already entered it
# manually in the weekly section, we don't double-count.
mk = month_key(txn_year, txn_month)
all_weekly = get_weekly()
prev = float(
all_weekly.get(mk, {})
.get(txn_week, {})
.get("sinking", {})
.get(fund_name, {})
.get("contributed") or 0
)
old_balance = float(f.get("balance", 0))
f["balance"] = round(old_balance - prev + txn_amount, 2)
# Log to weekly so the manual field shows it pre-filled
# and future edits to the weekly field do correct dedup
all_weekly.setdefault(mk, {}).setdefault(txn_week, {}).setdefault("sinking", {})[fund_name] = {
"contributed": txn_amount,
"from_transaction": txn_id,
}
save_weekly(all_weekly)
else:
# No date info — just bump the balance directly
f["balance"] = round(float(f.get("balance", 0)) + txn_amount, 2)
break
save_funds(funds)
if redirect_to == "review":
return redirect(url_for("review"))
return redirect("/transactions")
@app.route("/review/match-bnpl", methods=["POST"])
def match_bnpl():
"""Match a transaction to a BNPL loan during review — marks payment and categorizes."""
txn_id = request.form.get("txn_id")
debt_id = request.form.get("debt_id")
category = request.form.get("category", "Debt")
notes = request.form.get("notes", "")
# Mark the BNPL payment
debts = load_debts()
next_num = None
for d in debts:
if d["id"] == debt_id and d.get("is_bnpl"):
next_num = d.get("payments_made", 0) + 1
d["payments_made"] = next_num
remaining = d["total_payments"] - d["payments_made"]
d["current_balance"] = max(round(remaining * d["payment_amount"], 2), 0)
if d["payments_made"] >= d["total_payments"]:
d["active"] = False
notes = notes or d["name"]
break
save_debts(debts)
# Mark the corresponding bill as paid
if next_num:
bills = load_bills()
for b in bills:
if b.get("bnpl_debt_id") == debt_id and b.get("bnpl_payment_num") == next_num and not b.get("paid"):
b["paid"] = True
break
save_bills(bills)
# Categorize the transaction
transactions = get_transactions()
for t in transactions:
if t["id"] == txn_id:
t["category"] = category
t["subcategory"] = ""
t["notes"] = notes
t["reviewed"] = True
break
save_transactions(transactions)
return redirect(url_for("review"))
@app.route("/debts/delete/<debt_id>", methods=["POST"])
def debt_delete(debt_id):
debts = [d for d in load_debts() if d["id"] != debt_id]
save_debts(debts)
return redirect(url_for("debts_view"))
@app.route("/debts/log", methods=["POST"])
def debt_log_save():
debt_id = request.form.get("debt_id")
year = int(request.form.get("year"))
month = int(request.form.get("month"))
field = request.form.get("field")
value = request.form.get("value", "")
allowed = {"starting_balance", "payment", "new_charges", "interest"}
if field not in allowed:
return jsonify({"ok": False}), 400
mk = debt_month_key(year, month)
log = load_debt_log()
log.setdefault(mk, {}).setdefault(debt_id, {})[field] = float(value) if value else 0
save_debt_log(log)
debts = load_debts()
debt = next((d for d in debts if d["id"] == debt_id), None)
if debt:
entry = log[mk][debt_id]
starting = float(entry.get("starting_balance") or debt.get("current_balance", 0))
interest = float(entry.get("interest") if entry.get("interest") is not None
else monthly_interest(starting, debt.get("apr", 0)))
new_charges = float(entry.get("new_charges", 0))
payment = float(entry.get("payment", 0))
ending = max(round(starting + interest + new_charges - payment, 2), 0)
extra = max(round(payment - float(debt.get("minimum", 0)), 2), 0)
return jsonify({"ok": True, "ending": ending, "extra": extra, "interest": interest})
return jsonify({"ok": True})
@app.route("/debts/toggle-pending/<debt_id>", methods=["POST"])
def debt_toggle_pending(debt_id):
debts = load_debts()
for d in debts:
if d["id"] == debt_id:
d["statement_pending"] = not d.get("statement_pending", False)
break
save_debts(debts)
return jsonify({"ok": True, "pending": next(d["statement_pending"] for d in debts if d["id"] == debt_id)})
@app.route("/debts/edit/<debt_id>", methods=["POST"])
def debt_edit(debt_id):
debts = load_debts()
for d in debts:
if d["id"] == debt_id:
d["name"] = request.form.get("name", d["name"]).strip()
d["owner"] = request.form.get("owner", d.get("owner", "Bonna"))
v = request.form.get("current_balance", "").strip()
if v: d["current_balance"] = float(v)
v = request.form.get("apr", "").strip()
if v: d["apr"] = float(v)
v = request.form.get("minimum", "").strip()
if v: d["minimum"] = float(v)
v = request.form.get("credit_limit", "").strip()
if v: d["credit_limit"] = float(v)
d["notes"] = request.form.get("notes", "").strip()
break
save_debts(debts)
return redirect(url_for("debts_view"))
@app.route("/debts/add-from-statement", methods=["POST"])
def debt_add_from_statement():
import re, uuid
name = request.form.get("name", "").strip()
owner = request.form.get("owner", "Bonna")
text = request.form.get("text", "")
year = int(request.form.get("year"))
month = int(request.form.get("month"))
if not name or not text:
return jsonify({"error": "Name and statement text are required."})
def find_amount(patterns, txt):
for pat in patterns:
m = re.search(pat, txt, re.IGNORECASE)
if m:
return float(m.group(1).replace(",", ""))
return None
previous_balance = find_amount([r"previous balance\s*\$?([\d,]+\.\d{2})", r"previous balance[^\d]*([\d,]+\.\d{2})"], text)
purchases = find_amount([r"purchases & other charges\s*\$?([\d,]+\.\d{2})", r"purchases\s*\$?([\d,]+\.\d{2})", r"transactions total\s*\$?([\d,]+\.\d{2})"], text)
interest = find_amount([r"total interest for this period\s*\$?([\d,]+\.\d{2})", r"interest charged\s*\$?([\d,]+\.\d{2})", r"interest charge on purchases[^\d]*([\d,]+\.\d{2})", r"\+ interest charged\s*\$?([\d,]+\.\d{2})"], text)
payment = find_amount([r"payments & credits\s*\$?([\d,]+\.\d{2})", r"payments\s*\$?-?([\d,]+\.\d{2})", r"payment\s*\$?-?([\d,]+\.\d{2})"], text)
minimum = find_amount([r"minimum payment due\s*\$?([\d,]+\.\d{2})", r"minimum payment\s*\$?([\d,]+\.\d{2})"], text)
new_balance = find_amount([r"new balance\s*\$?([\d,]+\.\d{2})"], text)
credit_limit = find_amount([r"credit limit\s*\$?([\d,]+\.\d{2})"], text)
available_credit = find_amount([r"available credit\s*\$?([\d,]+\.\d{2})"], text)
apr = find_amount([r"purchase apr is ([\d.]+)%", r"purchases\s+([\d.]+)%", r"annual percentage rate \(apr\)\s*([\d.]+)", r"APR\)[^\d]*([\d.]+)", r"APR is ([\d.]+)%"], text)
if previous_balance is None and new_balance is None:
return jsonify({"error": "Could not find balance information. Make sure you pasted the full statement text."})
# Create the debt
debt_id = str(uuid.uuid4())
debt = {
"id": debt_id,
"name": name,
"debt_category": "Credit Cards",
"owner": owner,
"current_balance": new_balance or 0,
"apr": apr or 0,
"minimum": minimum or 0,
"credit_limit": credit_limit or 0,
"available_credit": available_credit or 0,
"account_match": "",
"notes": "",
"active": True,
}
debts = load_debts()
debts.append(debt)
save_debts(debts)
# Log this month's statement data
mk = debt_month_key(year, month)
log = load_debt_log()
entry = log.setdefault(mk, {}).setdefault(debt_id, {})
if previous_balance is not None: entry["starting_balance"] = previous_balance
if purchases is not None: entry["new_charges"] = purchases
if interest is not None: entry["interest"] = interest
if payment is not None: entry["payment"] = payment
save_debt_log(log)
return jsonify({"ok": True})
@app.route("/debts/parse-statement", methods=["POST"])
def debt_parse_statement():
import re
text = request.form.get("text", "")
debt_id = request.form.get("debt_id")
year = int(request.form.get("year"))
month = int(request.form.get("month"))
def find_amount(patterns, txt):
for pat in patterns:
m = re.search(pat, txt, re.IGNORECASE)
if m:
return float(m.group(1).replace(",", ""))
return None
previous_balance = find_amount([
r"previous balance\s*\$?([\d,]+\.\d{2})",
r"previous balance[^\d]*([\d,]+\.\d{2})",
], text)
purchases = find_amount([
r"purchases & other charges\s*\$?([\d,]+\.\d{2})",
r"purchases\s*\$?([\d,]+\.\d{2})",
r"transactions total\s*\$?([\d,]+\.\d{2})",
], text)
interest = find_amount([
r"total interest for this period\s*\$?([\d,]+\.\d{2})",
r"interest charged\s*\$?([\d,]+\.\d{2})",
r"interest charge on purchases[^\d]*([\d,]+\.\d{2})",
r"\+ interest charged\s*\$?([\d,]+\.\d{2})",
], text)
payment = find_amount([
r"payments & credits\s*\$?([\d,]+\.\d{2})",
r"payments\s*\$?-?([\d,]+\.\d{2})",
r"payment\s*\$?-?([\d,]+\.\d{2})",
], text)
minimum = find_amount([
r"minimum payment due\s*\$?([\d,]+\.\d{2})",
r"minimum payment\s*\$?([\d,]+\.\d{2})",
], text)
new_balance = find_amount([
r"new balance\s*\$?([\d,]+\.\d{2})",
], text)
credit_limit = find_amount([
r"credit limit\s*\$?([\d,]+\.\d{2})",
], text)
available_credit = find_amount([
r"available credit\s*\$?([\d,]+\.\d{2})",
], text)
apr = find_amount([
r"purchase apr is ([\d.]+)%",
r"purchases\s+([\d.]+)%",
r"annual percentage rate \(apr\)\s*([\d.]+)",
r"APR\)[^\d]*([\d.]+)",
r"APR is ([\d.]+)%",
], text)
if previous_balance is None and new_balance is None:
return jsonify({"error": "Could not find balance information. Make sure you pasted the full statement text."})
mk = debt_month_key(year, month)
log = load_debt_log()
entry = log.setdefault(mk, {}).setdefault(debt_id, {})
if previous_balance is not None:
entry["starting_balance"] = previous_balance
if purchases is not None:
entry["new_charges"] = purchases
if interest is not None:
entry["interest"] = interest
if payment is not None:
entry["payment"] = payment
save_debt_log(log)
# Update the debt itself with current balance, minimum, APR, credit info
debts = load_debts()
for d in debts:
if d["id"] == debt_id:
if new_balance is not None:
d["current_balance"] = new_balance
if minimum is not None:
d["minimum"] = minimum
if apr is not None:
d["apr"] = apr
if credit_limit is not None:
d["credit_limit"] = credit_limit
if available_credit is not None:
d["available_credit"] = available_credit
break
save_debts(debts)
return jsonify({
"ok": True,
"previous_balance": previous_balance or 0,
"purchases": purchases or 0,
"interest": interest or 0,
"payment": payment or 0,
"minimum": minimum or 0,
"new_balance": new_balance or 0,
"credit_limit": credit_limit,
"available_credit": available_credit,
})
@app.route("/debts/strategy", methods=["GET", "POST"])
def debt_strategy():
debts = load_debts()
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]
total_minimums = sum(float(d.get("minimum", 0)) for d in active)
total_balance = sum(float(d.get("current_balance", 0)) for d in active)
extra_monthly = float(request.form.get("extra_monthly") or 0)
total_monthly = total_minimums + extra_monthly
avalanche, av_interest, av_months, _ = simulate_payoff(debts, total_monthly, "avalanche")
snowball, sb_interest, sb_months, _ = simulate_payoff(debts, total_monthly, "snowball")
interest_saved = round(sb_interest - av_interest, 2)
months_diff = sb_months - av_months
return render_template("strategy.html",
avalanche=avalanche,
snowball=snowball,
av_interest=av_interest,
sb_interest=sb_interest,
av_months=av_months,
sb_months=sb_months,
interest_saved=interest_saved,
months_diff=months_diff,
total_minimums=total_minimums,
total_balance=total_balance,
extra_monthly=extra_monthly,
total_monthly=total_monthly,
owner_colors=OWNER_COLORS,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
)
@app.route("/sinking")
def sinking_view():
funds = load_funds()
total = sum(float(f.get("balance", 0)) for f in funds)
for f in funds:
if f.get("type") == "goal" and f.get("goal") and f.get("target_date"):
f["monthly_needed"] = monthly_needed(
float(f.get("balance", 0)), float(f["goal"]), f["target_date"])
f["months_left"] = months_until(f["target_date"])
goal = float(f["goal"])
bal = float(f.get("balance", 0))
f["pct"] = min(int((bal / goal * 100) if goal else 0), 100)
else:
f["monthly_needed"] = None
f["months_left"] = None
f["pct"] = None
return render_template("sinking.html",
funds=funds,
fund_colors=FUND_COLORS,
total=total,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
)
@app.route("/sinking/update", methods=["POST"])
def sinking_update():
funds = load_funds()
fund_name = request.form.get("name")
field = request.form.get("field")
value = request.form.get("value", "")
allowed = {"balance", "monthly", "goal", "target_date", "notes", "type"}
if field not in allowed:
return jsonify({"ok": False}), 400
for f in funds:
if f["name"] == fund_name:
if field in ("balance", "monthly", "goal"):
f[field] = float(value) if value else 0
else:
f[field] = value
break
save_funds(funds)
return jsonify({"ok": True})
@app.route("/sinking/contribute-weekly", methods=["POST"])
def sinking_contribute_weekly():
funds = load_funds()
fund_name = request.form.get("name")
amount = float(request.form.get("amount") or 0)
year = int(request.form.get("year"))
month = int(request.form.get("month"))
week = request.form.get("week")
new_balance = 0
# Update fund balance
for f in funds:
if f["name"] == fund_name:
old = float(f.get("balance", 0))
# Find previous contribution this week to avoid double-adding
mk = month_key(year, month)
all_weekly = get_weekly()
prev = float(all_weekly.get(mk, {}).get(week, {}).get("sinking", {}).get(fund_name, {}).get("contributed") or 0)
f["balance"] = round(old - prev + amount, 2)
new_balance = f["balance"]
break
save_funds(funds)
# Save contribution to weekly log
mk = month_key(year, month)
all_weekly = get_weekly()
all_weekly.setdefault(mk, {}).setdefault(week, {}).setdefault("sinking", {})[fund_name] = {"contributed": amount}
save_weekly(all_weekly)
return jsonify({"ok": True, "new_balance": new_balance})
@app.route("/sinking/contribute", methods=["POST"])
def sinking_contribute():
funds = load_funds()
fund_name = request.form.get("name")
amount = float(request.form.get("amount") or 0)
for f in funds:
if f["name"] == fund_name:
f["balance"] = round(float(f.get("balance", 0)) + amount, 2)
break
save_funds(funds)
return redirect(url_for("sinking_view"))
@app.route("/sinking/withdraw", methods=["POST"])
def sinking_withdraw():
funds = load_funds()
fund_name = request.form.get("name")
amount = float(request.form.get("amount") or 0)
for f in funds:
if f["name"] == fund_name:
f["balance"] = max(round(float(f.get("balance", 0)) - amount, 2), 0)
break
save_funds(funds)
return redirect(url_for("sinking_view"))
@app.route("/sinking/add", methods=["POST"])
def sinking_add():
funds = load_funds()
import uuid
fund = {
"id": str(uuid.uuid4()),
"name": request.form.get("name", "").strip(),
"type": request.form.get("type", "ongoing"),
"balance": float(request.form.get("balance") or 0),
"monthly": float(request.form.get("monthly") or 0),
"goal": float(request.form.get("goal") or 0) or None,
"target_date": request.form.get("target_date") or None,
"notes": request.form.get("notes", "").strip(),
}
funds.append(fund)
save_funds(funds)
return redirect(url_for("sinking_view"))
@app.route("/mortgage", methods=["GET", "POST"])
def mortgage_view():
data = load_mortgage()
if request.method == "POST":
data["principal"] = float(request.form.get("principal") or 0)
data["annual_rate"] = float(request.form.get("annual_rate") or 0)
data["term_years"] = int(request.form.get("term_years") or 30)
data["start_date"] = request.form.get("start_date") or ""
data["extra_monthly"] = float(request.form.get("extra_monthly") or 0)
save_mortgage(data)
principal = float(data.get("principal") or 0)
annual_rate = float(data.get("annual_rate") or 0)
term_years = int(data.get("term_years") or 30)
start_date = data.get("start_date") or ""
extra_monthly = float(data.get("extra_monthly") or 0)
base_payment = monthly_payment(principal, annual_rate, term_years) if principal else 0
current_bal = current_balance_estimate(principal, annual_rate, term_years, start_date) if principal and start_date else principal
schedule = []
total_interest_standard = 0
standard_months = 0
total_interest_extra = 0
extra_months = 0
bi_interest = 0
bi_payments = 0
bi_years = 0
bi_months_total = 0
if principal and annual_rate and start_date:
schedule, total_interest_standard, standard_months = amortization_schedule(
principal, annual_rate, term_years, start_date, 0)
if extra_monthly > 0:
_, total_interest_extra, extra_months = amortization_schedule(
principal, annual_rate, term_years, start_date, extra_monthly)
bi_interest, bi_payments, bi_years, bi_months_total = biweekly_schedule(
principal, annual_rate, term_years, start_date)
interest_saved_extra = round(total_interest_standard - total_interest_extra, 2) if extra_monthly else 0
months_saved_extra = standard_months - extra_months if extra_monthly else 0
interest_saved_biweekly = round(total_interest_standard - bi_interest, 2)
months_saved_biweekly = standard_months - bi_months_total
return render_template("mortgage.html",
data=data,
principal=principal,
annual_rate=annual_rate,
term_years=term_years,
start_date=start_date,
extra_monthly=extra_monthly,
base_payment=base_payment,
current_bal=current_bal,
schedule=schedule,
total_interest_standard=total_interest_standard,
standard_months=standard_months,
total_interest_extra=total_interest_extra,
extra_months=extra_months,
interest_saved_extra=interest_saved_extra,
months_saved_extra=months_saved_extra,
bi_interest=bi_interest,
bi_years=bi_years,
bi_months_total=bi_months_total,
interest_saved_biweekly=interest_saved_biweekly,
months_saved_biweekly=months_saved_biweekly,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
)
@app.route("/assets")
def assets_view():
data = load_assets()
personal = data.get("personal", [])
flips = data.get("flips", [])
for car in personal:
car["total_spent"] = total_spent(car)
car["display_name"] = car_display_name(car)
active_flips = [f for f in flips if not f.get("sold")]
sold_flips = [f for f in flips if f.get("sold")]
for flip in flips:
flip["display_name"] = car_display_name(flip)
flip["total_invested"] = flip_total_invested(flip)
flip["profit"] = flip_profit(flip)
total_tied_up = sum(f["total_invested"] for f in active_flips)
other_assets = data.get("other_assets", [])
by_category = {}
for cat in ASSET_CATEGORIES:
by_category[cat] = [a for a in other_assets if a.get("category") == cat]
total_other_value = sum(float(a.get("value") or 0) for a in other_assets)
total_personal_value = sum(float(c.get("estimated_value") or 0) for c in personal)
net_worth_assets = total_personal_value + total_other_value
return render_template("assets.html",
personal=personal,
active_flips=active_flips,
sold_flips=sold_flips,
total_tied_up=total_tied_up,
by_category=by_category,
asset_categories=ASSET_CATEGORIES,
net_worth_assets=net_worth_assets,
owner_colors=OWNER_COLORS,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
)
@app.route("/assets/personal/update/<car_id>", methods=["POST"])
def personal_car_update(car_id):
data = load_assets()
for car in data["personal"]:
if car["id"] == car_id:
for field in ("color", "owner", "purchase_price", "purchase_date",
"estimated_value", "notes"):
if field in request.form:
val = request.form.get(field)
car[field] = float(val) if field in ("purchase_price", "estimated_value") and val else val
break
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/personal/log/<car_id>", methods=["POST"])
def personal_car_log(car_id):
import uuid
data = load_assets()
log_type = request.form.get("log_type", "gas")
entry = {
"id": str(uuid.uuid4()),
"date": request.form.get("date", ""),
"amount": float(request.form.get("amount") or 0),
"notes": request.form.get("notes", ""),
}
if log_type == "gas":
entry["gallons"] = request.form.get("gallons", "")
else:
entry["description"] = request.form.get("description", "")
for car in data["personal"]:
if car["id"] == car_id:
if log_type == "gas":
car.setdefault("gas_log", []).append(entry)
else:
car.setdefault("maintenance_log", []).append(entry)
break
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/vehicle/add", methods=["POST"])
def vehicle_add():
import uuid
data = load_assets()
vehicle_type = request.form.get("vehicle_type", "personal")
year = int(request.form.get("year") or 0)
make = request.form.get("make", "").strip()
model = request.form.get("model", "").strip()
trim = request.form.get("trim", "").strip()
color = request.form.get("color", "").strip()
purchase_price = float(request.form.get("purchase_price") or 0)
purchase_date = request.form.get("purchase_date", "")
notes = request.form.get("notes", "").strip()
if vehicle_type == "flip":
flip = {
"id": str(uuid.uuid4()),
"type": "flip",
"year": year, "make": make, "model": model,
"trim": trim, "color": color,
"purchase_price": purchase_price,
"purchase_date": purchase_date,
"sale_price": 0, "sold": False, "sold_date": "",
"notes": notes, "expense_log": [],
}
data.setdefault("flips", []).append(flip)
else:
car = {
"id": str(uuid.uuid4()),
"type": "personal",
"year": year, "make": make, "model": model,
"trim": trim, "color": color,
"owner": request.form.get("owner", "Bonna"),
"purchase_price": purchase_price,
"purchase_date": purchase_date,
"estimated_value": float(request.form.get("estimated_value") or 0),
"notes": notes,
"gas_log": [], "maintenance_log": [],
}
data.setdefault("personal", []).append(car)
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/flip/add", methods=["POST"])
def flip_add():
import uuid
data = load_assets()
flip = {
"id": str(uuid.uuid4()),
"type": "flip",
"year": int(request.form.get("year") or 0),
"make": request.form.get("make", "").strip(),
"model": request.form.get("model", "").strip(),
"trim": request.form.get("trim", "").strip(),
"color": request.form.get("color", "").strip(),
"purchase_price": float(request.form.get("purchase_price") or 0),
"purchase_date": request.form.get("purchase_date", ""),
"sale_price": 0,
"sold": False,
"sold_date": "",
"notes": request.form.get("notes", "").strip(),
"expense_log": [],
}
personal_contribution = float(request.form.get("personal_contribution") or 0)
if personal_contribution > 0:
flip["expense_log"].append({
"id": str(uuid.uuid4()),
"date": request.form.get("purchase_date", ""),
"amount": personal_contribution,
"description": "Initial purchase from personal funds",
})
data.setdefault("flips", []).append(flip)
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/flip/expense/<flip_id>", methods=["POST"])
def flip_expense(flip_id):
import uuid
data = load_assets()
entry = {
"id": str(uuid.uuid4()),
"date": request.form.get("date", ""),
"amount": float(request.form.get("amount") or 0),
"description": request.form.get("description", ""),
}
for flip in data.get("flips", []):
if flip["id"] == flip_id:
flip.setdefault("expense_log", []).append(entry)
break
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/other/add", methods=["POST"])
def other_asset_add():
import uuid
data = load_assets()
asset = {
"id": str(uuid.uuid4()),
"name": request.form.get("name", "").strip(),
"category": request.form.get("category", "Other Valuables"),
"owner": request.form.get("owner", "Both"),
"value": float(request.form.get("value") or 0),
"purchase_price": float(request.form.get("purchase_price") or 0),
"purchase_date": request.form.get("purchase_date", ""),
"notes": request.form.get("notes", "").strip(),
}
data.setdefault("other_assets", []).append(asset)
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/other/delete/<asset_id>", methods=["POST"])
def other_asset_delete(asset_id):
data = load_assets()
data["other_assets"] = [a for a in data.get("other_assets", []) if a["id"] != asset_id]
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/other/update/<asset_id>", methods=["POST"])
def other_asset_update(asset_id):
data = load_assets()
for a in data.get("other_assets", []):
if a["id"] == asset_id:
new_value = float(request.form.get("value") or 0)
a["value"] = new_value
if request.form.get("notes") is not None:
a["notes"] = request.form.get("notes", "").strip()
# Append to value history, replacing today's entry if it exists
today_str = date.today().isoformat()
history = a.get("value_history", [])
if history and history[-1]["date"] == today_str:
history[-1]["value"] = new_value
else:
history.append({"date": today_str, "value": new_value})
a["value_history"] = history
break
save_assets(data)
return jsonify({"ok": True})
@app.route("/assets/flip/sell/<flip_id>", methods=["POST"])
def flip_sell(flip_id):
data = load_assets()
for flip in data.get("flips", []):
if flip["id"] == flip_id:
flip["sale_price"] = float(request.form.get("sale_price") or 0)
flip["sold_date"] = request.form.get("sold_date", "")
flip["sold"] = True
break
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/flip/edit/<flip_id>", methods=["POST"])
def flip_edit(flip_id):
data = load_assets()
for flip in data.get("flips", []):
if flip["id"] == flip_id:
flip["year"] = int(request.form.get("year") or flip["year"])
flip["make"] = request.form.get("make", flip["make"]).strip()
flip["model"] = request.form.get("model", flip["model"]).strip()
flip["trim"] = request.form.get("trim", flip.get("trim", "")).strip()
flip["color"] = request.form.get("color", flip.get("color", "")).strip()
flip["purchase_price"] = float(request.form.get("purchase_price") or flip.get("purchase_price", 0))
flip["purchase_date"] = request.form.get("purchase_date", flip.get("purchase_date", ""))
flip["notes"] = request.form.get("notes", flip.get("notes", "")).strip()
break
save_assets(data)
return redirect(url_for("assets_view"))
@app.route("/assets/flip/delete/<flip_id>", methods=["POST"])
def flip_delete(flip_id):
data = load_assets()
data["flips"] = [f for f in data.get("flips", []) if f["id"] != flip_id]
save_assets(data)
return redirect(url_for("assets_view"))
def ensure_tax_fund():
"""Create a Taxes sinking fund if one doesn't exist."""
import uuid
funds = load_funds()
if not any(f["name"] == "Taxes" for f in funds):
funds.append({
"id": str(uuid.uuid4()),
"name": "Taxes",
"goal": 0,
"balance": 0,
"monthly_target": 0,
"notes": "Set aside for estimated quarterly taxes",
})
save_funds(funds)
@app.route("/taxes")
def taxes_view():
ensure_tax_fund()
today = date.today()
tax_data = load_taxes()
rates = tax_data.get("rates", {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3})
set_aside_log = tax_data.get("set_aside", [])
# YTD income
ytd_by_stream, _ = income_ytd(today.year)
ytd_total = sum(ytd_by_stream.values())
# SE income = everything except Auto King (Tony's W-2 paycheck)
W2_STREAMS = {"Auto King"}
ytd_se = sum(v for s, v in ytd_by_stream.items() if s not in W2_STREAMS)
ytd_w2 = ytd_total - ytd_se
# Tax estimates
estimate = compute_tax_estimate(ytd_total, rates, se_income=ytd_se)
# Read set-aside total from the Taxes sinking fund balance
funds = load_funds()
tax_fund = next((f for f in funds if f["name"] == "Taxes"), None)
total_set_aside = round(float(tax_fund["balance"]) if tax_fund else 0, 2)
still_needed = round(max(estimate["total"] - total_set_aside, 0), 2)
over_saved = round(max(total_set_aside - estimate["total"], 0), 2)
# Per-quarter breakdown (Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec)
quarters = [
{"label": "Q1 (JanMar)", "months": [1,2,3]},
{"label": "Q2 (AprJun)", "months": [4,5,6]},
{"label": "Q3 (JulSep)", "months": [7,8,9]},
{"label": "Q4 (OctDec)", "months": [10,11,12]},
]
# Federal estimated tax due dates
quarter_due_dates = ["April 15", "June 16", "September 15", "January 15"]
for i, q in enumerate(quarters):
q_income = sum(
v for mk, v in [(income_month_key(today.year, m), 0) for m in q["months"]]
)
# Get actual income by month
q_total = 0
q_se = 0
for m in q["months"]:
monthly, _ = income_by_month(today.year, m)
q_total += sum(monthly.values())
q_se += sum(v for s, v in monthly.items() if s not in W2_STREAMS)
q["income"] = round(q_total, 2)
q["estimate"] = compute_tax_estimate(q_total, rates, se_income=q_se)
q["due_date"] = quarter_due_dates[i]
return render_template("taxes.html",
today=today,
year=today.year,
rates=rates,
ytd_total=ytd_total,
ytd_se=ytd_se,
ytd_w2=ytd_w2,
ytd_by_stream=ytd_by_stream,
estimate=estimate,
set_aside_log=set_aside_log,
total_set_aside=total_set_aside,
still_needed=still_needed,
over_saved=over_saved,
quarters=quarters,
)
@app.route("/taxes/set-aside", methods=["POST"])
def taxes_set_aside():
import uuid
amount = float(request.form.get("amount") or 0)
notes = request.form.get("notes", "")
entry_date = request.form.get("date", str(date.today()))
# Log to taxes.json for history
tax_data = load_taxes()
tax_data.setdefault("set_aside", []).append({
"id": str(uuid.uuid4()),
"date": entry_date,
"amount": amount,
"notes": notes,
})
save_taxes(tax_data)
# Add to the Taxes sinking fund balance
funds = load_funds()
for f in funds:
if f["name"] == "Taxes":
f["balance"] = round(float(f.get("balance", 0)) + amount, 2)
break
save_funds(funds)
return redirect(url_for("taxes_view"))
@app.route("/taxes/set-aside/delete/<entry_id>", methods=["POST"])
def taxes_set_aside_delete(entry_id):
tax_data = load_taxes()
entry = next((e for e in tax_data.get("set_aside", []) if e["id"] == entry_id), None)
if entry:
# Subtract from the Taxes sinking fund
funds = load_funds()
for f in funds:
if f["name"] == "Taxes":
f["balance"] = round(max(float(f.get("balance", 0)) - float(entry.get("amount", 0)), 0), 2)
break
save_funds(funds)
tax_data["set_aside"] = [e for e in tax_data.get("set_aside", []) if e["id"] != entry_id]
save_taxes(tax_data)
return redirect(url_for("taxes_view"))
@app.route("/taxes/rates", methods=["POST"])
def taxes_rates():
tax_data = load_taxes()
tax_data["rates"] = {
"se_tax": float(request.form.get("se_tax") or 15.3),
"federal": float(request.form.get("federal") or 22.0),
"wi_state": float(request.form.get("wi_state") or 5.3),
}
save_taxes(tax_data)
return redirect(url_for("taxes_view"))
@app.route("/net-worth")
def net_worth_view():
accounts = load_nw_accounts()
funds = load_funds()
asset_data = load_assets()
debts = load_debts()
total_cash = sum(float(a.get("balance") or 0) for a in accounts)
total_sinking = sum(float(f.get("balance") or 0) for f in funds)
personal_cars = asset_data.get("personal", [])
for car in personal_cars:
car["display_name"] = car_display_name(car)
total_vehicles = sum(float(c.get("estimated_value") or 0) for c in personal_cars)
other_assets = asset_data.get("other_assets", [])
total_other_assets = sum(float(a.get("value") or 0) for a in other_assets)
total_assets = total_cash + total_sinking + total_vehicles + total_other_assets
debts_by_cat = {cat: [] for cat in DEBT_CATEGORIES}
debt_cat_totals = {cat: 0.0 for cat in DEBT_CATEGORIES}
for d in debts:
if not d.get("active", True):
continue
cat = d.get("debt_category", "Credit Cards")
if cat in debts_by_cat:
debts_by_cat[cat].append(d)
debt_cat_totals[cat] += float(d.get("current_balance") or 0)
total_debts = sum(debt_cat_totals.values())
net_worth = round(total_assets - total_debts, 2)
return render_template("net_worth.html",
accounts=accounts,
sinking_funds=funds,
personal_cars=personal_cars,
other_assets=other_assets,
debts_by_cat=debts_by_cat,
debt_cat_totals=debt_cat_totals,
debt_categories=DEBT_CATEGORIES,
total_cash=total_cash,
total_sinking=total_sinking,
total_vehicles=total_vehicles,
total_other_assets=total_other_assets,
total_assets=total_assets,
total_debts=total_debts,
net_worth=net_worth,
owner_colors=OWNER_COLORS,
)
@app.route("/net-worth/account/add", methods=["POST"])
def nw_account_add():
import uuid
accounts = load_nw_accounts()
accounts.append({
"id": str(uuid.uuid4())[:8],
"name": request.form.get("name", "").strip(),
"owner": request.form.get("owner", "Bonna"),
"balance": float(request.form.get("balance") or 0),
})
save_nw_accounts(accounts)
return redirect(url_for("net_worth_view"))
@app.route("/net-worth/account/update/<acc_id>", methods=["POST"])
def nw_account_update(acc_id):
accounts = load_nw_accounts()
for a in accounts:
if a["id"] == acc_id:
a["balance"] = float(request.form.get("balance") or 0)
break
save_nw_accounts(accounts)
return jsonify({"ok": True})
@app.route("/net-worth/account/delete/<acc_id>", methods=["POST"])
def nw_account_delete(acc_id):
accounts = load_nw_accounts()
accounts = [a for a in accounts if a["id"] != acc_id]
save_nw_accounts(accounts)
return redirect(url_for("net_worth_view"))
@app.route("/month")
@app.route("/month/<int:year>/<int:month>")
def month_summary(year=None, month=None):
today = date.today()
year = year or today.year
month = month or today.month
mk = month_key(year, month)
prev_month = month - 1 or 12
prev_year = year if month > 1 else year - 1
next_month = month % 12 + 1
next_year = year if month < 12 else year + 1
# --- Income ---
log = load_income_log()
month_income_entries = [e for e in log if e.get("month") == mk]
income_by_stream = {}
for e in month_income_entries:
s = e["stream"]
income_by_stream[s] = round(income_by_stream.get(s, 0) + float(e.get("amount", 0)), 2)
total_income = sum(income_by_stream.values())
# --- Transactions (categorized, date falls in this month) ---
from dateutil import parser as date_parser
all_txns = get_transactions()
month_txns = []
for t in all_txns:
if not t.get("reviewed") or not t.get("category"):
continue
try:
d = date_parser.parse(t["date"]).date()
except Exception:
continue
if d.year == year and d.month == month:
month_txns.append(t)
skip_cats = {"Income", "Debt", "Transfer"}
spending_by_cat = {}
for t in month_txns:
cat = t.get("category")
if cat and cat not in skip_cats:
spending_by_cat[cat] = round(spending_by_cat.get(cat, 0) + float(t.get("amount", 0)), 2)
total_spending = sum(spending_by_cat.values())
# --- Bills due this month ---
monthly_bills = bills_for_month(year, month)
total_bills = sum(float(b.get("amount") or 0) for b in monthly_bills)
# --- Debt payments from debt log ---
debt_log = load_debt_log()
month_debt = debt_log.get(mk, {})
debts = load_debts()
debt_payments = []
total_debt_paid = 0
total_extra_paid = 0
for d in debts:
if not d.get("active", True):
continue
entry = month_debt.get(d["id"], {})
payment = float(entry.get("payment", 0))
minimum = float(d.get("minimum", 0))
extra = max(round(payment - minimum, 2), 0)
if payment > 0:
debt_payments.append({"name": d["name"], "owner": d.get("owner",""), "payment": payment, "extra": extra})
total_debt_paid += payment
total_extra_paid += extra
# Also pull extra debt payments from weekly log
all_weekly = get_weekly()
month_weekly = all_weekly.get(mk, {})
extra_from_weekly = 0
for wk_data in month_weekly.values():
for debt_id, info in wk_data.get("extra_debt", {}).items():
extra_from_weekly += float(info.get("extra") or 0)
# --- Sinking fund contributions from weekly log ---
sinking_contributions = {}
for wk_data in month_weekly.values():
for fund_name, info in wk_data.get("sinking", {}).items():
amt = float(info.get("contributed") or 0)
sinking_contributions[fund_name] = round(sinking_contributions.get(fund_name, 0) + amt, 2)
total_sinking = sum(sinking_contributions.values())
# --- Budgeted amounts from weekly ---
budgeted_by_cat = {}
for wk_data in month_weekly.values():
for cat, info in wk_data.get("categories", {}).items():
b = float(info.get("budgeted") or 0)
budgeted_by_cat[cat] = round(budgeted_by_cat.get(cat, 0) + b, 2)
# --- Net ---
total_out = total_spending + total_bills + total_sinking
net = round(total_income - total_out, 2)
return render_template("month.html",
year=year,
month=month,
month_name=cal_module.month_name[month],
prev_year=prev_year, prev_month=prev_month,
next_year=next_year, next_month=next_month,
total_income=total_income,
income_by_stream=income_by_stream,
income_streams=INCOME_STREAMS,
total_spending=total_spending,
spending_by_cat=spending_by_cat,
budgeted_by_cat=budgeted_by_cat,
total_bills=total_bills,
monthly_bills=monthly_bills,
debt_payments=debt_payments,
total_debt_paid=total_debt_paid,
total_extra_paid=total_extra_paid,
sinking_contributions=sinking_contributions,
total_sinking=total_sinking,
total_out=total_out,
net=net,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
owner_colors=INCOME_OWNER_COLORS,
)
@app.route("/income")
@app.route("/income/<int:year>/<int:month>")
def income_view(year=None, month=None):
today = date.today()
year = year or today.year
month = month or today.month
by_stream, entries = income_by_month(year, month)
ytd_by_stream, ytd_by_month = income_ytd(year)
stream_names = [s["name"] for s in INCOME_STREAMS]
month_total = sum(by_stream.values())
ytd_total = sum(ytd_by_stream.values())
# Build sorted month list for YTD chart
sorted_months = sorted(ytd_by_month.keys())
prev_month = month - 1 or 12
prev_year = year if month > 1 else year - 1
next_month = month % 12 + 1
next_year = year if month < 12 else year + 1
# Tax estimate for income page
tax_data = load_taxes()
tax_rates = tax_data.get("rates", {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3})
ytd_se = sum(v for s, v in ytd_by_stream.items() if s != "Auto King")
tax_estimate = compute_tax_estimate(ytd_total, tax_rates, se_income=ytd_se)
all_funds = load_funds()
tax_fund = next((f for f in all_funds if f["name"] == "Taxes"), None)
tax_set_aside = round(float(tax_fund["balance"]) if tax_fund else 0, 2)
tax_still_needed = round(max(tax_estimate["total"] - tax_set_aside, 0), 2)
return render_template("income.html",
year=year,
month=month,
month_name=cal_module.month_name[month],
month_names=cal_module.month_name,
prev_year=prev_year,
prev_month=prev_month,
next_year=next_year,
next_month=next_month,
income_streams=INCOME_STREAMS,
by_stream=by_stream,
entries=entries,
month_total=month_total,
ytd_by_stream=ytd_by_stream,
ytd_by_month=ytd_by_month,
sorted_months=sorted_months,
ytd_total=ytd_total,
owner_colors=INCOME_OWNER_COLORS,
categories=CATEGORIES,
colors=CATEGORY_COLORS,
tax_estimate=tax_estimate,
tax_set_aside=tax_set_aside,
tax_still_needed=tax_still_needed,
)
@app.route("/income/log", methods=["POST"])
def income_log():
stream = request.form.get("stream")
amount = float(request.form.get("amount") or 0)
month_str = request.form.get("month_str")
note = request.form.get("note", "")
if stream and amount and month_str:
log_income_entry(stream, amount, month_str, note)
return redirect(request.referrer or url_for("income_view"))
@app.route("/income/backfill", methods=["POST"])
def income_backfill():
year = int(request.form.get("year"))
log = load_income_log()
existing = {(e["stream"], e["month"]) for e in log}
for m in range(1, 13):
mk = income_month_key(year, m)
for s in INCOME_STREAMS:
field = f"amt_{m}_{s['name'].replace(' ', '_')}"
val = request.form.get(field, "").strip()
if not val:
continue
try:
amount = float(val)
except ValueError:
continue
if amount <= 0:
continue
if (s["name"], mk) in existing:
continue
log_income_entry(s["name"], amount, mk, note="backfill")
existing.add((s["name"], mk))
return redirect(url_for("income_view", year=year))
@app.route("/income/delete/<entry_id>", methods=["POST"])
def income_delete(entry_id):
delete_income_entry(entry_id)
year = request.form.get("year", date.today().year)
month = request.form.get("month", date.today().month)
return redirect(url_for("income_view", year=year, month=month))
if __name__ == "__main__":
app.run(debug=True, port=5000)