From fa3c030b36b24ca6f0c7a212c95b0695d3a1c809 Mon Sep 17 00:00:00 2001 From: Bonna Date: Sat, 6 Jun 2026 14:22:57 -0500 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Moon=20Household?= =?UTF-8?q?=20Budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 29 + .gitignore | 17 + app.py | 2861 +++++++++++++++++ assets.py | 86 + bills.py | 82 + categories.py | 37 + debts.py | 194 ++ income.py | 82 + .../backup/Dockerfile | 13 + .../backup/backup.sh | 34 + .../docker/.dockerignore | 15 + .../docker/.env.example | 24 + .../docker/Dockerfile | 39 + .../docker/docker-compose.yml | 32 + .../docker/entrypoint.sh | 19 + .../gitignore.example | 29 + .../suggestions/gitea.py | 116 + .../suggestions/routes_fastapi.py | 121 + .../suggestions/routes_flask.py | 146 + .../suggestions/static/suggestions.js | 87 + .../suggestions/templates/suggestions.html | 46 + mortgage.py | 124 + reminders.py | 125 + sinking.py | 56 + static/style.css | 1737 ++++++++++ templates/assets.html | 486 +++ templates/base.html | 71 + templates/bills.html | 159 + templates/calendar.html | 50 + templates/debts.html | 774 +++++ templates/edit.html | 234 ++ templates/import.html | 50 + templates/income.html | 277 ++ templates/index.html | 385 +++ templates/month.html | 244 ++ templates/mortgage.html | 157 + templates/net_worth.html | 225 ++ templates/review.html | 396 +++ templates/sinking.html | 175 + templates/strategy.html | 116 + templates/summary.html | 71 + templates/taxes.html | 187 ++ templates/transactions.html | 168 + templates/weekly.html | 484 +++ 44 files changed, 10860 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 app.py create mode 100644 assets.py create mode 100644 bills.py create mode 100644 categories.py create mode 100644 debts.py create mode 100644 income.py create mode 100644 moon-household-budget-handoff/backup/Dockerfile create mode 100644 moon-household-budget-handoff/backup/backup.sh create mode 100644 moon-household-budget-handoff/docker/.dockerignore create mode 100644 moon-household-budget-handoff/docker/.env.example create mode 100644 moon-household-budget-handoff/docker/Dockerfile create mode 100644 moon-household-budget-handoff/docker/docker-compose.yml create mode 100644 moon-household-budget-handoff/docker/entrypoint.sh create mode 100644 moon-household-budget-handoff/gitignore.example create mode 100644 moon-household-budget-handoff/suggestions/gitea.py create mode 100644 moon-household-budget-handoff/suggestions/routes_fastapi.py create mode 100644 moon-household-budget-handoff/suggestions/routes_flask.py create mode 100644 moon-household-budget-handoff/suggestions/static/suggestions.js create mode 100644 moon-household-budget-handoff/suggestions/templates/suggestions.html create mode 100644 mortgage.py create mode 100644 reminders.py create mode 100644 sinking.py create mode 100644 static/style.css create mode 100644 templates/assets.html create mode 100644 templates/base.html create mode 100644 templates/bills.html create mode 100644 templates/calendar.html create mode 100644 templates/debts.html create mode 100644 templates/edit.html create mode 100644 templates/import.html create mode 100644 templates/income.html create mode 100644 templates/index.html create mode 100644 templates/month.html create mode 100644 templates/mortgage.html create mode 100644 templates/net_worth.html create mode 100644 templates/review.html create mode 100644 templates/sinking.html create mode 100644 templates/strategy.html create mode 100644 templates/summary.html create mode 100644 templates/taxes.html create mode 100644 templates/transactions.html create mode 100644 templates/weekly.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6be0890 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "allow": [ + "Bash(pip3 install *)", + "Bash(python -c \"from app import app; print\\('OK'\\)\")", + "Bash(python3 -c ' *)", + "Bash(launchctl load *)", + "Bash(sed -i '' 's/total_spending + total_bills | money/\\(total_spending + total_bills\\) | money/g' index.html)", + "Bash(sed -i '' 's/a\\\\.balance or 0 | money/\\(a.balance or 0\\) | money/g' index.html net_worth.html)", + "Bash(sed -i '' 's/f\\\\.balance or 0 | money/\\(f.balance or 0\\) | money/g' weekly.html sinking.html net_worth.html edit.html review.html)", + "Bash(sed -i '' 's/d\\\\.minimum or 0 | money/\\(d.minimum or 0\\) | money/g' weekly.html)", + "Bash(sed -i '' 's/base_payment \\\\/ 2 | money/\\(base_payment \\\\/ 2\\) | money/g' mortgage.html)", + "Bash(sed -i '' 's/by_stream\\\\.get\\(s\\\\.name, 0 | money\\)/by_stream.get\\(s.name, 0\\) | money/g' income.html)", + "Bash(sed -i '' 's/ytd_by_stream\\\\.get\\(s\\\\.name, 0 | money\\)/ytd_by_stream.get\\(s.name, 0\\) | money/g' income.html)", + "Bash(sed -i '' 's/ytd_total \\\\/ months_with_income if months_with_income else 0 | money/\\(ytd_total \\\\/ months_with_income if months_with_income else 0\\) | money/g' income.html)", + "Bash(sed -i '' 's/actual - budgeted | money/\\(actual - budgeted\\) | money/g' weekly.html)", + "Bash(sed -i '' 's/amt - budgeted | money/\\(amt - budgeted\\) | money/g' transactions.html)", + "Bash(sed -i '' 's/base_payment + extra_monthly | money/\\(base_payment + extra_monthly\\) | money/g' mortgage.html)", + "Bash(sed -i '' 's/total_spending + total_bills | money/\\(total_spending + total_bills\\) | money/g' month.html)", + "Bash(find /Users/bonnamoon/Projects/fullmoonbakehouse -name \"*moon*handoff*\" -o -name \"*handoff*\" 2>/dev/null; find /Users/bonnamoon -maxdepth 3 -name \"*handoff*\" 2>/dev/null)", + "Bash(curl -fsS -o /dev/null -w \"Gitea reachable: %{http_code}\\\\n\" http://192.168.0.5:3022/)", + "Bash(ssh -o ConnectTimeout=5 root@192.168.0.5 'echo NAS reachable && docker --version')", + "Bash(cat >> *)", + "Bash(grep -v \"^$\")", + "Bash(git init *)", + "Bash(git add *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8092e66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.env +*.env +!.env.example +data/ +*.sqlite +*.sqlite3 +*.db +*.db-journal +__pycache__/ +*.pyc +.venv/ +venv/ +env/ +.DS_Store +.idea/ +.vscode/ +bonnasclaude.md diff --git a/app.py b/app.py new file mode 100644 index 0000000..164268b --- /dev/null +++ b/app.py @@ -0,0 +1,2861 @@ +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 text.""" + result = _re.sub(r'\*\*(.+?)\*\*', r'\1', 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/", 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//") +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//", 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/", 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/", 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//") +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//") +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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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 (Jan–Mar)", "months": [1,2,3]}, + {"label": "Q2 (Apr–Jun)", "months": [4,5,6]}, + {"label": "Q3 (Jul–Sep)", "months": [7,8,9]}, + {"label": "Q4 (Oct–Dec)", "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/", 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/", 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/", 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//") +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//") +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/", 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) diff --git a/assets.py b/assets.py new file mode 100644 index 0000000..3915562 --- /dev/null +++ b/assets.py @@ -0,0 +1,86 @@ +import json +import os + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +ASSETS_FILE = os.path.join(DATA_DIR, "assets.json") + +DEFAULT_CARS = [ + { + "id": "kia-forte", + "type": "personal", + "year": 2012, + "make": "Kia", + "model": "Forte 5 SX", + "trim": "2.4L", + "color": "", + "owner": "Bonna", + "purchase_price": 0, + "purchase_date": "", + "estimated_value": 0, + "notes": "", + "gas_log": [], + "maintenance_log": [], + }, + { + "id": "silverado", + "type": "personal", + "year": 2009, + "make": "Chevy", + "model": "Silverado LTZ", + "trim": "5.3L", + "color": "", + "owner": "Tony", + "purchase_price": 0, + "purchase_date": "", + "estimated_value": 0, + "notes": "", + "gas_log": [], + "maintenance_log": [], + }, +] + + +ASSET_CATEGORIES = ["Investments", "Real Estate", "Equipment", "Other Valuables"] + + +def load_assets(): + if os.path.exists(ASSETS_FILE): + with open(ASSETS_FILE) as f: + data = json.load(f) + # migrate old format if needed + if "other_assets" not in data: + data["other_assets"] = [] + return data + save_assets({"personal": DEFAULT_CARS, "flips": [], "other_assets": []}) + return {"personal": DEFAULT_CARS, "flips": [], "other_assets": []} + + +def save_assets(data): + with open(ASSETS_FILE, "w") as f: + json.dump(data, f, indent=2) + + +def car_display_name(car): + return f"{car['year']} {car['make']} {car['model']}" + + +def total_spent(car): + gas = sum(float(e.get("amount", 0)) for e in car.get("gas_log", [])) + maintenance = sum(float(e.get("amount", 0)) for e in car.get("maintenance_log", [])) + return round(gas + maintenance, 2) + + +def flip_profit(flip): + invested = float(flip.get("purchase_price", 0)) + repairs = sum(float(e.get("amount", 0)) for e in flip.get("expense_log", [])) + sale = float(flip.get("sale_price", 0)) + total_in = invested + repairs + if sale > 0: + return round(sale - total_in, 2) + return None + + +def flip_total_invested(flip): + invested = float(flip.get("purchase_price", 0)) + repairs = sum(float(e.get("amount", 0)) for e in flip.get("expense_log", [])) + return round(invested + repairs, 2) diff --git a/bills.py b/bills.py new file mode 100644 index 0000000..c62564c --- /dev/null +++ b/bills.py @@ -0,0 +1,82 @@ +import json +import os +from datetime import date, timedelta +import calendar + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +BILLS_FILE = os.path.join(DATA_DIR, "bills.json") + + +def load_bills(): + if os.path.exists(BILLS_FILE): + with open(BILLS_FILE) as f: + return json.load(f) + return [] + + +def save_bills(bills): + with open(BILLS_FILE, "w") as f: + json.dump(bills, f, indent=2) + + +def get_week_label(day): + if day <= 7: + return "Week 1 (1–7)" + elif day <= 14: + return "Week 2 (8–14)" + elif day <= 21: + return "Week 3 (15–21)" + else: + return "Week 4 (22–end)" + + +def get_week_num(day): + if day <= 7: + return 1 + elif day <= 14: + return 2 + elif day <= 21: + return 3 + else: + return 4 + + +def bills_for_month(year, month): + from datetime import datetime + all_bills = load_bills() + _, num_days = calendar.monthrange(year, month) + result = [] + for b in all_bills: + if not b.get("active", True): + continue + # Specific-date bills (BNPL payments) + if b.get("due_date_str"): + try: + d = datetime.strptime(b["due_date_str"], "%Y-%m-%d").date() + if d.year == year and d.month == month: + result.append({**b, "due_date": d, "due_day": d.day}) + except ValueError: + pass + # Recurring monthly bills + elif b.get("due_day"): + due_day = b["due_day"] + if 1 <= due_day <= num_days: + result.append({**b, "due_date": date(year, month, due_day)}) + return sorted(result, key=lambda x: x.get("due_day", 0)) + + +def calendar_grid(year, month): + _, num_days = calendar.monthrange(year, month) + first_weekday = calendar.monthrange(year, month)[0] # 0=Mon + # Convert to Sun-first + first_weekday = (first_weekday + 1) % 7 + + days = [] + for _ in range(first_weekday): + days.append(None) + for d in range(1, num_days + 1): + days.append(d) + while len(days) % 7 != 0: + days.append(None) + + return [days[i:i+7] for i in range(0, len(days), 7)] diff --git a/categories.py b/categories.py new file mode 100644 index 0000000..e6e3aaf --- /dev/null +++ b/categories.py @@ -0,0 +1,37 @@ +CATEGORIES = { + "Food": ["Groceries", "Restaurants & Takeout", "Coffee Shops"], + "Home": ["Supplies", "Tools", "Kitchen Tools", "Furnishings", "Art & Decor", "Garden & Landscaping"], + "Pets": [], + "Health": ["Health Insurance", "Medical Payment Plans", "OTC & Prescriptions"], + "Self Care": ["Skincare", "Makeup & Beauty", "Hygiene"], + "Car": ["Gas", "Maintenance & Repairs"], + "Shopping": [], + "Fun": [], + "Utilities": [], + "Subscriptions": [], + "Business": ["Studio", "Bakery", "Car Flipping"], + "Travel": [], + "Misc": ["Gifts", "Donations", "Fees & Fines", "Personal Allowance", "Other"], + "Debt": ["Credit Cards", "BNPL", "Loans & Payment Plans", "Medical Debt"], + "Income": ["Auto King", "Studio", "Bakery", "Misc"], + "Transfer": ["Savings", "Investment", "Sinking Fund"], +} + +CATEGORY_COLORS = { + "Food": "#8FA899", # slate sage + "Home": "#A8906E", # warm clay + "Pets": "#7A9BAD", # muted denim + "Health": "#9A8AAA", # dusty plum + "Self Care": "#AA8A90", # faded mauve + "Car": "#8A9878", # muted olive + "Shopping": "#A89A6A", # greyed gold + "Fun": "#A87860", # muted burnt sienna + "Utilities": "#6E88A0", # slate blue + "Subscriptions": "#7A70A0", # greyed indigo + "Business": "#6A9898", # desaturated teal + "Travel": "#907860", # warm umber + "Misc": "#909090", # cool grey + "Debt": "#A87878", # muted rose red + "Income": "#7A9E78", # desaturated green + "Transfer": "#8890A8", # blue grey +} diff --git a/debts.py b/debts.py new file mode 100644 index 0000000..59a3311 --- /dev/null +++ b/debts.py @@ -0,0 +1,194 @@ +import json +import os +from datetime import date +import math + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +DEBTS_FILE = os.path.join(DATA_DIR, "debts.json") +DEBT_LOG_FILE = os.path.join(DATA_DIR, "debt_log.json") + +DEBT_CATEGORIES = ["Credit Cards", "BNPL", "Medical Payment Plans"] + +BNPL_FREQUENCIES = { + "biweekly": 14, + "monthly": 30, + "4-payment": 14, +} + + +def simulate_payoff(debts, extra_monthly, method="avalanche"): + import copy + from datetime import date + from dateutil.relativedelta import relativedelta + + # Only credit cards and medical debts with a balance + active = [d for d in debts if not d.get("is_bnpl") and d.get("active", True) + and float(d.get("current_balance", 0)) > 0] + if not active: + return [], 0, 0, 0 + + # Build working copies + working = [] + for d in active: + working.append({ + "id": d["id"], + "name": d["name"], + "owner": d.get("owner", ""), + "balance": float(d.get("current_balance", 0)), + "apr": float(d.get("apr", 0)), + "minimum": float(d.get("minimum", 0)), + "paid_off_month": None, + "total_interest": 0, + "total_paid": 0, + }) + + total_minimum = sum(w["minimum"] for w in working) + extra = max(extra_monthly - total_minimum, 0) + + current_month = date.today().replace(day=1) + total_interest = 0 + months = 0 + max_months = 600 + + while any(w["balance"] > 0 for w in working) and months < max_months: + months += 1 + current_month_label = current_month.strftime("%b %Y") + + # Sort target debt + unpaid = [w for w in working if w["balance"] > 0] + if method == "avalanche": + unpaid.sort(key=lambda x: -x["apr"]) + else: + unpaid.sort(key=lambda x: x["balance"]) + + # Apply interest and minimums + for w in unpaid: + if w["balance"] <= 0: + continue + monthly_rate = (w["apr"] / 100) / 12 + interest = round(w["balance"] * monthly_rate, 2) + w["balance"] = round(w["balance"] + interest, 2) + w["total_interest"] += interest + total_interest += interest + payment = min(w["minimum"], w["balance"]) + w["balance"] = round(w["balance"] - payment, 2) + w["total_paid"] += payment + + # Apply extra to target + remaining_extra = extra + for w in unpaid: + if w["balance"] <= 0: + continue + extra_payment = min(remaining_extra, w["balance"]) + w["balance"] = round(w["balance"] - extra_payment, 2) + w["total_paid"] += extra_payment + remaining_extra -= extra_payment + if remaining_extra <= 0: + break + + # Roll over freed minimums as extra for next debt + for w in working: + if w["balance"] <= 0 and w["paid_off_month"] is None: + w["paid_off_month"] = current_month_label + extra += w["minimum"] + + from datetime import timedelta + current_month = (current_month.replace(day=28) + timedelta(days=4)).replace(day=1) + + return working, round(total_interest, 2), months, round(total_minimum + extra_monthly, 2) + + +def bnpl_payoff_date(payments_made, total_payments, frequency_days): + from datetime import date, timedelta + today = date.today() + remaining = total_payments - payments_made + if remaining <= 0: + return None, 0 + days = remaining * frequency_days + payoff = today + timedelta(days=days) + return payoff.strftime("%b %Y"), remaining + +OWNER_COLORS = { + "Bonna": "#D4B8C5", + "Tony": "#A0B8C5", + "Both": "#B8A8D4", +} + + +def load_debts(): + if os.path.exists(DEBTS_FILE): + with open(DEBTS_FILE) as f: + return json.load(f) + return [] + + +def save_debts(debts): + with open(DEBTS_FILE, "w") as f: + json.dump(debts, f, indent=2) + + +def load_debt_log(): + if os.path.exists(DEBT_LOG_FILE): + with open(DEBT_LOG_FILE) as f: + return json.load(f) + return {} + + +def save_debt_log(log): + with open(DEBT_LOG_FILE, "w") as f: + json.dump(log, f, indent=2) + + +def month_key(year, month): + return f"{year}-{month:02d}" + + +def monthly_interest(balance, apr): + if not apr or apr <= 0: + return 0 + return round(balance * (apr / 100) / 12, 2) + + +def payoff_months(balance, apr, monthly_payment, monthly_charges=0): + if monthly_payment <= 0: + return None + if apr and apr > 0: + monthly_rate = (apr / 100) / 12 + else: + monthly_rate = 0 + + b = balance + months = 0 + while b > 0 and months < 600: + b = b + (b * monthly_rate) + monthly_charges - monthly_payment + months += 1 + if monthly_charges == 0 and b >= balance: + return None + return months if months < 600 else None + + +def get_debt_month(debt_id, year, month): + log = load_debt_log() + mk = month_key(year, month) + return log.get(mk, {}).get(debt_id, {}) + + +def update_debt_month(debt_id, year, month, data): + log = load_debt_log() + mk = month_key(year, month) + log.setdefault(mk, {})[debt_id] = data + save_debt_log(log) + + +def compute_ending_balance(debt, year, month): + mk = month_key(year, month) + log = load_debt_log() + entry = log.get(mk, {}).get(debt["id"], {}) + + starting = entry.get("starting_balance", debt.get("current_balance", 0)) + interest = entry.get("interest") if entry.get("interest") is not None else monthly_interest(starting, debt.get("apr", 0)) + new_charges = entry.get("new_charges", 0) + payment = entry.get("payment", 0) + + ending = round(float(starting) + float(interest) + float(new_charges) - float(payment), 2) + return max(ending, 0) diff --git a/income.py b/income.py new file mode 100644 index 0000000..7dc98d8 --- /dev/null +++ b/income.py @@ -0,0 +1,82 @@ +import json +import os +from datetime import date + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +INCOME_FILE = os.path.join(DATA_DIR, "income_log.json") + +INCOME_STREAMS = [ + {"name": "Auto King", "owner": "Tony", "type": "paycheck"}, + {"name": "Studio", "owner": "Bonna", "type": "business"}, + {"name": "Bakery", "owner": "Bonna", "type": "business"}, + {"name": "Car Flipping","owner": "Tony", "type": "business"}, + {"name": "Misc", "owner": "Both", "type": "misc"}, +] + +OWNER_COLORS = { + "Bonna": "#D4B8C5", + "Tony": "#A0B8C5", + "Both": "#B8A8D4", +} + + +def load_income_log(): + if os.path.exists(INCOME_FILE): + with open(INCOME_FILE) as f: + return json.load(f) + return [] + + +def save_income_log(log): + with open(INCOME_FILE, "w") as f: + json.dump(log, f, indent=2) + + +def month_key(year, month): + return f"{year}-{month:02d}" + + +def income_by_month(year, month): + log = load_income_log() + mk = month_key(year, month) + entries = [e for e in log if e.get("month") == mk] + by_stream = {} + for e in entries: + s = e["stream"] + by_stream[s] = by_stream.get(s, 0) + float(e.get("amount", 0)) + return by_stream, entries + + +def income_ytd(year): + log = load_income_log() + by_stream = {} + by_month = {} + for e in log: + mk = e.get("month", "") + if not mk.startswith(str(year)): + continue + s = e["stream"] + amt = float(e.get("amount", 0)) + by_stream[s] = by_stream.get(s, 0) + amt + by_month[mk] = by_month.get(mk, 0) + amt + return by_stream, by_month + + +def log_income_entry(stream, amount, month_str, note="", entry_id=None): + import uuid + log = load_income_log() + log.append({ + "id": entry_id or str(uuid.uuid4()), + "stream": stream, + "amount": float(amount), + "month": month_str, + "date": date.today().isoformat(), + "note": note, + }) + save_income_log(log) + + +def delete_income_entry(entry_id): + log = load_income_log() + log = [e for e in log if e.get("id") != entry_id] + save_income_log(log) diff --git a/moon-household-budget-handoff/backup/Dockerfile b/moon-household-budget-handoff/backup/Dockerfile new file mode 100644 index 0000000..df6850e --- /dev/null +++ b/moon-household-budget-handoff/backup/Dockerfile @@ -0,0 +1,13 @@ +# Tiny rclone + sqlite sidecar that backs up /data to Backblaze B2 on a loop. +FROM alpine:3.20 + +RUN apk add --no-cache rclone sqlite tzdata + +COPY backup.sh /backup.sh +RUN chmod +x /backup.sh + +ENV BACKUP_INTERVAL=86400 + +# Run once on boot, then every BACKUP_INTERVAL seconds. A failed run is logged +# but does not kill the loop. +CMD ["sh", "-c", "while true; do /backup.sh || echo '[backup] FAILED'; sleep \"${BACKUP_INTERVAL}\"; done"] diff --git a/moon-household-budget-handoff/backup/backup.sh b/moon-household-budget-handoff/backup/backup.sh new file mode 100644 index 0000000..73e6bbd --- /dev/null +++ b/moon-household-budget-handoff/backup/backup.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Snapshot the app's data to Backblaze B2 via rclone. +# - SQLite DB: a consistent .backup snapshot (safe while the app is running) +# - uploads: mirrored to B2 +# rclone's B2 remote ("b2:") is configured entirely from RCLONE_CONFIG_B2_* +# env vars (set in .env / compose). Runs once; the container loops it. +set -eu + +STAMP=$(date +%Y%m%d-%H%M%S) +DEST="b2:${B2_BUCKET}/moon-household-budget" +KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" + +echo "[backup ${STAMP}] starting" + +# 1. SQLite snapshot (only if a db file is present) +if [ -f /data/app.db ]; then + sqlite3 /data/app.db ".backup '/tmp/app-${STAMP}.db'" + rclone copyto "/tmp/app-${STAMP}.db" "${DEST}/db/app-${STAMP}.db" + rm -f "/tmp/app-${STAMP}.db" + echo "[backup ${STAMP}] db snapshot uploaded" +else + echo "[backup ${STAMP}] no /data/app.db — skipping db snapshot" +fi + +# 2. Uploaded images (and anything else under /data except the live db) +if [ -d /data/uploads ]; then + rclone sync /data/uploads "${DEST}/uploads" + echo "[backup ${STAMP}] uploads synced" +fi + +# 3. Prune old db snapshots +rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/db" 2>/dev/null || true + +echo "[backup ${STAMP}] done" diff --git a/moon-household-budget-handoff/docker/.dockerignore b/moon-household-budget-handoff/docker/.dockerignore new file mode 100644 index 0000000..1bf53bd --- /dev/null +++ b/moon-household-budget-handoff/docker/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +__pycache__/ +*.pyc +.venv/ +venv/ +env/ +.env +data/ +*.sqlite +*.sqlite3 +*.db +.DS_Store +node_modules/ +bonnasclaude.md diff --git a/moon-household-budget-handoff/docker/.env.example b/moon-household-budget-handoff/docker/.env.example new file mode 100644 index 0000000..3f0b2b3 --- /dev/null +++ b/moon-household-budget-handoff/docker/.env.example @@ -0,0 +1,24 @@ +# Copy to `.env` and fill in. NEVER commit the real .env (it's gitignored). +# Tony will hand over the two B2 keys and confirm the Gitea token. + +# ---- App ---- +PORT=8000 +SECRET_KEY=__GENERATE_A_RANDOM_STRING__ +# SQLite on the mounted volume — keeps the stack self-contained: +DATABASE_URL=sqlite:////data/app.db +# Public URL the app is reached at (used for absolute upload links): +APP_ORIGIN=http://192.168.0.5:8052 + +# ---- Suggestions → Gitea issues ---- +GITEA_URL=http://192.168.0.5:3022 +GITEA_TOKEN=__BONNA_GITEA_TOKEN__ +GITEA_REPO=bonna61/moon-household-budget + +# ---- Backblaze B2 backup (read by the rclone sidecar) ---- +# rclone reads its remote config straight from these env vars: +RCLONE_CONFIG_B2_TYPE=b2 +RCLONE_CONFIG_B2_ACCOUNT=__B2_KEY_ID__ +RCLONE_CONFIG_B2_KEY=__B2_APPLICATION_KEY__ +B2_BUCKET=moon-household-budget-backup +BACKUP_INTERVAL=86400 # seconds between backups (86400 = daily) +BACKUP_KEEP_DAYS=30 # prune DB snapshots older than this diff --git a/moon-household-budget-handoff/docker/Dockerfile b/moon-household-budget-handoff/docker/Dockerfile new file mode 100644 index 0000000..fb4fc00 --- /dev/null +++ b/moon-household-budget-handoff/docker/Dockerfile @@ -0,0 +1,39 @@ +# Default image for a pip-based Python web app (Flask via gunicorn). +# ADJUST the CMD at the bottom to match the app's entry point. +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 + +# sqlite3 CLI is used by the backup snapshot; curl for healthchecks. +RUN apt-get update \ + && apt-get install -y --no-install-recommends sqlite3 curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install -r requirements.txt && pip install gunicorn requests + +COPY . . + +# Persistent data lives on a mounted volume, not in the image. +RUN mkdir -p /data/uploads/suggestions + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 +ENTRYPOINT ["/entrypoint.sh"] + +# ---- ADJUST THIS LINE to the app's real entry point ---- +# Flask (app object named `app` in app.py): +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] +# +# FastAPI (app object named `app` in main.py) — swap the CMD for: +# RUN pip install "uvicorn[standard]" +# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# +# Django (project package `mysite`): +# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"] +# +# Streamlit: +# CMD ["streamlit", "run", "app.py", "--server.port=8000", "--server.address=0.0.0.0"] diff --git a/moon-household-budget-handoff/docker/docker-compose.yml b/moon-household-budget-handoff/docker/docker-compose.yml new file mode 100644 index 0000000..2602dbf --- /dev/null +++ b/moon-household-budget-handoff/docker/docker-compose.yml @@ -0,0 +1,32 @@ +# Moon Household Budget — self-contained stack. +# app : the Python web app (SQLite on the ./data volume — no DB container) +# backup : rclone sidecar that snapshots ./data to Backblaze B2 on a loop +# +# Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/. +# Bring up with: docker compose up -d --build +services: + app: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: moon-household-budget + restart: unless-stopped + ports: + - "8052:8000" # http://192.168.0.5:8052 + env_file: .env + volumes: + - ./data:/data # SQLite db + uploaded images live here + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/", "||", "exit", "1"] + interval: 30s + timeout: 5s + retries: 3 + + backup: + build: + context: ../backup + container_name: moon-budget-backup + restart: unless-stopped + env_file: .env + volumes: + - ./data:/data:ro # read-only — backup never writes app data diff --git a/moon-household-budget-handoff/docker/entrypoint.sh b/moon-household-budget-handoff/docker/entrypoint.sh new file mode 100644 index 0000000..f3ee8ef --- /dev/null +++ b/moon-household-budget-handoff/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Runs before the app starts. Ensures the data dir exists and runs DB +# migrations if the app uses them. Uncomment the lines that apply. +set -e + +mkdir -p /data/uploads/suggestions + +# --- Database migrations (pick what the app uses, leave the rest commented) --- +# Flask-Migrate / Alembic via Flask: +# flask db upgrade +# Raw Alembic: +# alembic upgrade head +# Django: +# python manage.py migrate --noinput +# python manage.py collectstatic --noinput +# Plain SQLite with a schema file (only first run): +# [ -f /data/app.db ] || sqlite3 /data/app.db < schema.sql + +exec "$@" diff --git a/moon-household-budget-handoff/gitignore.example b/moon-household-budget-handoff/gitignore.example new file mode 100644 index 0000000..740c858 --- /dev/null +++ b/moon-household-budget-handoff/gitignore.example @@ -0,0 +1,29 @@ +# Append these to the project's .gitignore (create one if missing). +# CRITICAL: secrets and the live database must never be committed. + +# Secrets / env +.env +*.env +!.env.example + +# Local database + uploads (live data lives on the NAS volume) +data/ +*.sqlite +*.sqlite3 +*.db +*.db-journal + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ +env/ + +# OS / editor +.DS_Store +.idea/ +.vscode/ + +# This handoff instruction file — don't ship it into the repo +bonnasclaude.md diff --git a/moon-household-budget-handoff/suggestions/gitea.py b/moon-household-budget-handoff/suggestions/gitea.py new file mode 100644 index 0000000..0c966a7 --- /dev/null +++ b/moon-household-budget-handoff/suggestions/gitea.py @@ -0,0 +1,116 @@ +""" +Gitea-backed suggestions module — framework-agnostic core. + +Files user-submitted suggestions as issues in a Gitea repo, mirroring the +pattern used by Tony's `dms` and `fullmoonbakehouse` apps. No database of its +own — Gitea issues ARE the storage. + +Configure via environment variables: + GITEA_URL e.g. http://192.168.0.5:3022 + GITEA_TOKEN a Gitea access token with repo issue read/write scope + GITEA_REPO "owner/repo", e.g. bonna61/moon-household-budget + +If any are unset, gitea_config() returns None and callers should degrade +gracefully (show "feedback isn't set up yet" instead of crashing). + +Depends only on `requests` (add it to requirements.txt if not already there). +""" +from __future__ import annotations + +import os +import secrets +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import requests + +ALLOWED_IMAGE_TYPES = { + "image/jpeg", "image/png", "image/webp", + "image/gif", "image/heic", "image/heif", +} +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB + + +@dataclass +class GiteaConfig: + base_url: str + token: str + repo: str # "owner/repo" + + +def gitea_config() -> Optional[GiteaConfig]: + base = os.environ.get("GITEA_URL") + token = os.environ.get("GITEA_TOKEN") + repo = os.environ.get("GITEA_REPO") + if not (base and token and repo): + return None + return GiteaConfig(base.rstrip("/"), token, repo) + + +class GiteaError(RuntimeError): + pass + + +class GiteaClient: + def __init__(self, cfg: GiteaConfig): + self.cfg = cfg + + def _url(self, path: str) -> str: + return f"{self.cfg.base_url}/api/v1/repos/{self.cfg.repo}{path}" + + def _req(self, method: str, path: str, **kw): + headers = { + "Authorization": f"token {self.cfg.token}", + "Content-Type": "application/json", + } + r = requests.request(method, self._url(path), headers=headers, timeout=15, **kw) + if not r.ok: + raise GiteaError(f"Gitea {r.status_code}: {r.text[:300]}") + return r.json() + + def list_issues(self, state: str = "open"): + return self._req("GET", f"/issues?type=issues&state={state}&limit=50") + + def create_issue(self, title: str, body: str): + return self._req("POST", "/issues", json={"title": title, "body": body}) + + def list_comments(self, number: int): + return self._req("GET", f"/issues/{number}/comments") + + def create_comment(self, number: int, body: str): + return self._req("POST", f"/issues/{number}/comments", json={"body": body}) + + +def sniff_image(buf: bytes) -> Optional[str]: + """Return a MIME type by inspecting magic bytes, or None if unrecognized.""" + if buf[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if buf[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if buf[:4] == b"GIF8": + return "image/gif" + if buf[:4] == b"RIFF" and buf[8:12] == b"WEBP": + return "image/webp" + if buf[4:8] == b"ftyp": + brand = buf[8:12].decode("ascii", "ignore") + if brand in ("heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs"): + return "image/heic" + if brand in ("mif1", "msf1"): + return "image/heif" + return None + + +def save_upload(filename: str, data: bytes, upload_dir: Path, origin: str) -> str: + """Persist an upload and return an absolute URL for embedding in markdown.""" + upload_dir.mkdir(parents=True, exist_ok=True) + ext = Path(filename).suffix.lower() or ".bin" + safe = f"{int(time.time() * 1000)}-{secrets.token_urlsafe(8)}{ext}" + (upload_dir / safe).write_bytes(data) + return f"{origin.rstrip('/')}/uploads/suggestions/{safe}" + + +def footer(name: str, email: str, kind: str = "user") -> str: + """Attribution footer appended to a filed issue body (dms/fmb style).""" + return f"\n\n---\n_Filed by {name} ({email}) · {kind}_" diff --git a/moon-household-budget-handoff/suggestions/routes_fastapi.py b/moon-household-budget-handoff/suggestions/routes_fastapi.py new file mode 100644 index 0000000..0b5bc7c --- /dev/null +++ b/moon-household-budget-handoff/suggestions/routes_fastapi.py @@ -0,0 +1,121 @@ +""" +FastAPI adapter for the Gitea-backed suggestions module. + +Use this INSTEAD of routes_flask.py if the app is FastAPI. Wire it in with: + + from suggestions.routes_fastapi import router as suggestions_router + app.include_router(suggestions_router) + +AUTH: replace `current_user()` with the app's real dependency. It must return +something exposing `.name` and `.email`, or None. +""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, File, Request, UploadFile +from fastapi.responses import JSONResponse + +from .gitea import ( + ALLOWED_IMAGE_TYPES, + MAX_UPLOAD_BYTES, + GiteaClient, + GiteaError, + footer, + gitea_config, + save_upload, + sniff_image, +) + +router = APIRouter() +UPLOAD_DIR = Path("/data/uploads/suggestions") + + +def current_user(): + """ADAPT THIS to the app's real auth. Return obj with .name/.email or None.""" + return type("U", (), {"name": "Household", "email": "household@local"})() + + +def _client_or_error(): + cfg = gitea_config() + if not cfg: + return None, JSONResponse({"error": "not_configured"}, status_code=503) + return GiteaClient(cfg), None + + +@router.get("/api/suggestions") +def list_suggestions(state: str = "open"): + if current_user() is None: + return JSONResponse({"error": "unauthorized"}, status_code=401) + client, err = _client_or_error() + if err: + return err + try: + return {"issues": client.list_issues(state)} + except GiteaError as e: + return JSONResponse({"error": str(e)}, status_code=502) + + +@router.post("/api/suggestions") +async def create_suggestion(request: Request): + user = current_user() + if user is None: + return JSONResponse({"error": "unauthorized"}, status_code=401) + client, err = _client_or_error() + if err: + return err + data = await request.json() + title = (data.get("title") or "").strip() + if len(title) < 3: + return JSONResponse({"error": "title_required"}, status_code=400) + body = (data.get("body") or "").strip() + footer(user.name, user.email) + try: + return JSONResponse({"issue": client.create_issue(title, body)}, status_code=201) + except GiteaError as e: + return JSONResponse({"error": str(e)}, status_code=502) + + +@router.get("/api/suggestions/{number}/comments") +def list_comments(number: int): + if current_user() is None: + return JSONResponse({"error": "unauthorized"}, status_code=401) + client, err = _client_or_error() + if err: + return err + try: + return {"comments": client.list_comments(number)} + except GiteaError as e: + return JSONResponse({"error": str(e)}, status_code=502) + + +@router.post("/api/suggestions/{number}/comments") +async def add_comment(number: int, request: Request): + user = current_user() + if user is None: + return JSONResponse({"error": "unauthorized"}, status_code=401) + client, err = _client_or_error() + if err: + return err + data = await request.json() + text = (data.get("body") or "").strip() + if not text: + return JSONResponse({"error": "body_required"}, status_code=400) + text += f"\n\n_— {user.name}_" + try: + return JSONResponse({"comment": client.create_comment(number, text)}, status_code=201) + except GiteaError as e: + return JSONResponse({"error": str(e)}, status_code=502) + + +@router.post("/api/suggestions/upload") +async def upload(request: Request, file: UploadFile = File(...)): + if current_user() is None: + return JSONResponse({"error": "unauthorized"}, status_code=401) + data = await file.read() + if len(data) > MAX_UPLOAD_BYTES: + return JSONResponse({"error": "too_large"}, status_code=413) + if sniff_image(data) not in ALLOWED_IMAGE_TYPES: + return JSONResponse({"error": "unsupported_type"}, status_code=415) + origin = str(request.base_url) + url = save_upload(file.filename or "image", data, UPLOAD_DIR, origin) + return JSONResponse({"url": url}, status_code=201) diff --git a/moon-household-budget-handoff/suggestions/routes_flask.py b/moon-household-budget-handoff/suggestions/routes_flask.py new file mode 100644 index 0000000..cacb112 --- /dev/null +++ b/moon-household-budget-handoff/suggestions/routes_flask.py @@ -0,0 +1,146 @@ +""" +Flask adapter for the Gitea-backed suggestions module. + +Wire it into the app with: + + from suggestions.routes_flask import suggestions_bp + app.register_blueprint(suggestions_bp) + +Then a logged-in user can open /suggestions to file feedback. The blueprint +exposes both the HTML page and the JSON API the page's JS talks to. + +AUTH: replace `current_user()` below with however this app identifies the +logged-in person. It must return an object with `.name` and `.email`, or None. +If the app has no auth at all, you can return a fixed identity (see the stub). +""" +from __future__ import annotations + +from pathlib import Path + +from flask import Blueprint, jsonify, render_template, request + +from .gitea import ( + ALLOWED_IMAGE_TYPES, + MAX_UPLOAD_BYTES, + GiteaClient, + GiteaError, + footer, + gitea_config, + save_upload, + sniff_image, +) + +suggestions_bp = Blueprint( + "suggestions", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/suggestions-static", +) + +# Where uploaded images land. Lives under the mounted /data volume so backups +# and restarts keep them. Served at /uploads/suggestions/ (see below). +UPLOAD_DIR = Path("/data/uploads/suggestions") + + +def current_user(): + """Resolve the logged-in user. ADAPT THIS to the app's real auth. + + Must return an object exposing `.name` and `.email`, or None if anonymous. + """ + # --- Example: Flask-Login --- + # from flask_login import current_user as u + # return u if getattr(u, "is_authenticated", False) else None + # + # --- Example: session dict --- + # from flask import session + # if "user_email" in session: + # return type("U", (), {"name": session.get("user_name", "User"), + # "email": session["user_email"]})() + # return None + # + # --- Stub: single-household app with no real auth --- + return type("U", (), {"name": "Household", "email": "household@local"})() + + +def _guard(): + return current_user() is not None + + +@suggestions_bp.route("/suggestions") +def suggestions_page(): + return render_template("suggestions.html", configured=gitea_config() is not None) + + +@suggestions_bp.route("/api/suggestions", methods=["GET", "POST"]) +def suggestions_api(): + if not _guard(): + return jsonify({"error": "unauthorized"}), 401 + cfg = gitea_config() + if not cfg: + return jsonify({"error": "not_configured"}), 503 + client = GiteaClient(cfg) + + if request.method == "GET": + state = request.args.get("state", "open") + try: + return jsonify({"issues": client.list_issues(state)}) + except GiteaError as e: + return jsonify({"error": str(e)}), 502 + + data = request.get_json(silent=True) or {} + title = (data.get("title") or "").strip() + if len(title) < 3: + return jsonify({"error": "title_required"}), 400 + body = (data.get("body") or "").strip() + user = current_user() + if user is not None: + body += footer(user.name, user.email) + try: + return jsonify({"issue": client.create_issue(title, body)}), 201 + except GiteaError as e: + return jsonify({"error": str(e)}), 502 + + +@suggestions_bp.route("/api/suggestions//comments", methods=["GET", "POST"]) +def suggestion_comments(number: int): + if not _guard(): + return jsonify({"error": "unauthorized"}), 401 + cfg = gitea_config() + if not cfg: + return jsonify({"error": "not_configured"}), 503 + client = GiteaClient(cfg) + + if request.method == "GET": + try: + return jsonify({"comments": client.list_comments(number)}) + except GiteaError as e: + return jsonify({"error": str(e)}), 502 + + data = request.get_json(silent=True) or {} + text = (data.get("body") or "").strip() + if not text: + return jsonify({"error": "body_required"}), 400 + user = current_user() + if user is not None: + text += f"\n\n_— {user.name}_" + try: + return jsonify({"comment": client.create_comment(number, text)}), 201 + except GiteaError as e: + return jsonify({"error": str(e)}), 502 + + +@suggestions_bp.route("/api/suggestions/upload", methods=["POST"]) +def suggestions_upload(): + if not _guard(): + return jsonify({"error": "unauthorized"}), 401 + if "file" not in request.files: + return jsonify({"error": "file_required"}), 400 + f = request.files["file"] + data = f.read(MAX_UPLOAD_BYTES + 1) + if len(data) > MAX_UPLOAD_BYTES: + return jsonify({"error": "too_large"}), 413 + if sniff_image(data) not in ALLOWED_IMAGE_TYPES: + return jsonify({"error": "unsupported_type"}), 415 + url = save_upload(f.filename or "image", data, UPLOAD_DIR, request.host_url) + return jsonify({"url": url}), 201 diff --git a/moon-household-budget-handoff/suggestions/static/suggestions.js b/moon-household-budget-handoff/suggestions/static/suggestions.js new file mode 100644 index 0000000..e9d6ad3 --- /dev/null +++ b/moon-household-budget-handoff/suggestions/static/suggestions.js @@ -0,0 +1,87 @@ +// Minimal vanilla-JS client for the suggestions page. Talks to the JSON API +// exposed by routes_flask.py / routes_fastapi.py. No build step, no framework. + +const $ = (id) => document.getElementById(id); + +async function loadIssues() { + const list = $("s-list"); + if (!list) return; + try { + const res = await fetch("/api/suggestions?state=open"); + const data = await res.json(); + const issues = data.issues || []; + if (!issues.length) { + list.innerHTML = '

No open suggestions yet.

'; + return; + } + list.innerHTML = issues.map((i) => ` +
+
#${i.number} ${escapeHtml(i.title)}
+
${i.comments} comment(s) · + view on Gitea
+
`).join(""); + } catch (e) { + list.innerHTML = '

Could not load suggestions.

'; + } +} + +function escapeHtml(s) { + return (s || "").replace(/[&<>"']/g, (c) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] + )); +} + +async function uploadImage(file) { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/suggestions/upload", { method: "POST", body: fd }); + if (!res.ok) throw new Error("upload failed"); + return (await res.json()).url; +} + +function wireComposer() { + const send = $("s-send"); + const body = $("s-body"); + if (!send || !body) return; + + // Paste-to-attach: drop an image embed into the body. + body.addEventListener("paste", async (ev) => { + const item = [...(ev.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); + if (!item) return; + ev.preventDefault(); + $("s-status").textContent = "Uploading image…"; + try { + const url = await uploadImage(item.getAsFile()); + body.value += `\n\n![pasted image](${url})`; + $("s-status").textContent = "Image attached."; + } catch { + $("s-status").textContent = "Image upload failed."; + } + }); + + send.addEventListener("click", async () => { + const title = $("s-title").value.trim(); + if (title.length < 3) { $("s-status").textContent = "Add a longer title."; return; } + send.disabled = true; + $("s-status").textContent = "Sending…"; + try { + const res = await fetch("/api/suggestions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body: body.value }), + }); + if (!res.ok) throw new Error(); + $("s-title").value = ""; + body.value = ""; + $("s-status").textContent = "Thanks! Sent."; + loadIssues(); + } catch { + $("s-status").textContent = "Could not send — try again."; + } finally { + send.disabled = false; + } + }); +} + +wireComposer(); +loadIssues(); diff --git a/moon-household-budget-handoff/suggestions/templates/suggestions.html b/moon-household-budget-handoff/suggestions/templates/suggestions.html new file mode 100644 index 0000000..54f8fd0 --- /dev/null +++ b/moon-household-budget-handoff/suggestions/templates/suggestions.html @@ -0,0 +1,46 @@ + + + + + + Suggestions · Moon Household Budget + + + +

💡 Suggestions & Feedback

+

Ideas, bugs, “can it also do…?” — they go straight to Tony.

+ + {% if not configured %} +
Feedback isn’t set up yet (Gitea env vars missing). Once configured, this page goes live.
+ {% else %} +
+ + +
+ + +
+
+

Open suggestions

+

Loading…

+ {% endif %} + + + + diff --git a/mortgage.py b/mortgage.py new file mode 100644 index 0000000..b783918 --- /dev/null +++ b/mortgage.py @@ -0,0 +1,124 @@ +import json +import os +from datetime import date +from dateutil.relativedelta import relativedelta +import math + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +MORTGAGE_FILE = os.path.join(DATA_DIR, "mortgage.json") + + +def load_mortgage(): + if os.path.exists(MORTGAGE_FILE): + with open(MORTGAGE_FILE) as f: + return json.load(f) + return {} + + +def save_mortgage(data): + with open(MORTGAGE_FILE, "w") as f: + json.dump(data, f, indent=2) + + +def monthly_payment(principal, annual_rate, term_years): + if annual_rate == 0: + return round(principal / (term_years * 12), 2) + r = (annual_rate / 100) / 12 + n = term_years * 12 + payment = principal * (r * (1 + r) ** n) / ((1 + r) ** n - 1) + return round(payment, 2) + + +def amortization_schedule(principal, annual_rate, term_years, start_date_str, extra_monthly=0): + r = (annual_rate / 100) / 12 if annual_rate > 0 else 0 + n = term_years * 12 + base_payment = monthly_payment(principal, annual_rate, term_years) + total_payment = base_payment + extra_monthly + + try: + current = date.fromisoformat(start_date_str) + except Exception: + current = date.today().replace(day=1) + + schedule = [] + balance = principal + total_interest = 0 + total_principal = 0 + + for i in range(1, n + 1): + if balance <= 0: + break + interest = round(balance * r, 2) + principal_paid = min(round(total_payment - interest, 2), balance) + if principal_paid < 0: + principal_paid = 0 + balance = round(balance - principal_paid, 2) + total_interest += interest + total_principal += principal_paid + + schedule.append({ + "month": i, + "date": current.strftime("%b %Y"), + "payment": round(interest + principal_paid, 2), + "principal": principal_paid, + "interest": interest, + "balance": max(balance, 0), + }) + + if balance <= 0: + break + current = current + relativedelta(months=1) + + return schedule, round(total_interest, 2), len(schedule) + + +def biweekly_schedule(principal, annual_rate, term_years, start_date_str): + base = monthly_payment(principal, annual_rate, term_years) + biweekly_payment = round(base / 2, 2) + r_biweekly = (annual_rate / 100) / 26 if annual_rate > 0 else 0 + + try: + current = date.fromisoformat(start_date_str) + except Exception: + current = date.today().replace(day=1) + + balance = principal + total_interest = 0 + payments = 0 + max_payments = term_years * 26 + 52 + + while balance > 0 and payments < max_payments: + interest = round(balance * r_biweekly, 2) + principal_paid = min(round(biweekly_payment - interest, 2), balance) + if principal_paid < 0: + principal_paid = 0 + balance = round(balance - principal_paid, 2) + total_interest += interest + payments += 1 + if balance <= 0: + break + + years = payments / 26 + months_total = round(payments / 26 * 12) + return round(total_interest, 2), payments, round(years, 1), months_total + + +def current_balance_estimate(principal, annual_rate, term_years, start_date_str, extra_monthly=0): + schedule, _, _ = amortization_schedule( + principal, annual_rate, term_years, start_date_str, extra_monthly) + today = date.today() + for row in schedule: + try: + row_date = date.strptime(row["date"], "%b %Y") if False else None + except Exception: + pass + # Count months elapsed + try: + start = date.fromisoformat(start_date_str) + months_elapsed = (today.year - start.year) * 12 + (today.month - start.month) + months_elapsed = max(0, months_elapsed) + if months_elapsed < len(schedule): + return schedule[months_elapsed]["balance"] + return 0 + except Exception: + return principal diff --git a/reminders.py b/reminders.py new file mode 100644 index 0000000..58fa358 --- /dev/null +++ b/reminders.py @@ -0,0 +1,125 @@ +import smtplib +import subprocess +import json +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import date, timedelta +import calendar as cal_module +from bills import load_bills + +EMAIL = "bonna61@gmail.com" +APP_PASSWORD = "qowu zcff dbtk kqbs" + + +def send_email(subject, body_html, body_text): + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"Moon Household Budget <{EMAIL}>" + msg["To"] = EMAIL + msg.attach(MIMEText(body_text, "plain")) + msg.attach(MIMEText(body_html, "html")) + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(EMAIL, APP_PASSWORD) + server.sendmail(EMAIL, EMAIL, msg.as_string()) + + +def mac_notification(title, message): + script = f'display notification "{message}" with title "{title}" sound name "Default"' + subprocess.run(["osascript", "-e", script]) + + +def bills_due_in(days): + today = date.today() + target = today + timedelta(days=days) + all_bills = load_bills() + due = [] + for b in all_bills: + if not b.get("active", True): + continue + if b.get("autopay", False): + continue + if not b.get("remind", True): + continue + due_day = b.get("due_day") + if not due_day: + continue + _, num_days = cal_module.monthrange(target.year, target.month) + due_day_clamped = min(due_day, num_days) + bill_date = date(target.year, target.month, due_day_clamped) + if bill_date == target: + due.append({**b, "due_date": bill_date}) + return due + + +def check_and_send_reminders(): + sent = [] + + for days_ahead, label in [(3, "in 3 days"), (0, "TODAY")]: + due = bills_due_in(days_ahead) + if not due: + continue + + total = sum(b.get("amount", 0) for b in due if b.get("amount")) + + # Mac notifications — one per bill + for b in due: + amt = f"${b['amount']:.2f}" if b.get("amount") else "amount varies" + mac_notification( + f"Bill Due {label.title()}", + f"{b['name']} — {amt}" + ) + + # Email — one digest + subject = f"🌙 Moon Budget: {len(due)} bill{'s' if len(due) != 1 else ''} due {label}" + + rows = "" + for b in due: + amt = f"${b['amount']:.2f}" if b.get("amount") else "—" + cat = b.get("category", "") + rows += f""" + + {b['name']} + {amt} + {cat} + """ + + total_row = f"${total:.2f}" if total else "" + + body_html = f""" +
+

🌙 Moon Household Budget

+

Bill reminder — due {label}

+ + + + + + + + + {rows} + {f'' if total_row else ''} +
BillAmountCategory
Total: {total_row}
+

Moon Household Budget · sent to bonna61@gmail.com

+
""" + + body_text = f"Moon Budget — Bills due {label}:\n\n" + for b in due: + amt = f"${b['amount']:.2f}" if b.get("amount") else "varies" + body_text += f" • {b['name']} — {amt}\n" + if total: + body_text += f"\nTotal: ${total:.2f}\n" + + send_email(subject, body_html, body_text) + sent.append(f"{len(due)} bills due {label}") + + return sent + + +if __name__ == "__main__": + results = check_and_send_reminders() + if results: + print("Sent reminders:", ", ".join(results)) + else: + print("No reminders to send today.") diff --git a/sinking.py b/sinking.py new file mode 100644 index 0000000..8006a31 --- /dev/null +++ b/sinking.py @@ -0,0 +1,56 @@ +import json +import os +from datetime import date + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +SINKING_FILE = os.path.join(DATA_DIR, "sinking_funds.json") + +FUND_COLORS = { + "Travel": "#C5BBA0", + "Vet & Pets": "#A0B8C5", + "Holidays & Gifts": "#D4B8C5", + "Renovations": "#C5A8A0", + "Studio Build": "#B4A0C5", + "Emergency Fund": "#A8C5A0", +} + +DEFAULT_FUNDS = [ + {"name": "Travel", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""}, + {"name": "Vet & Pets", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""}, + {"name": "Holidays & Gifts", "type": "ongoing", "monthly": 0, "balance": 0, "notes": ""}, + {"name": "Renovations", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Subcategories coming later"}, + {"name": "Studio Build", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Goal amount TBD"}, + {"name": "Emergency Fund", "type": "ongoing", "monthly": 0, "balance": 0, "notes": "Target: 3–6 months expenses"}, +] + + +def load_funds(): + if os.path.exists(SINKING_FILE): + with open(SINKING_FILE) as f: + return json.load(f) + # First run — seed with defaults + save_funds(DEFAULT_FUNDS) + return DEFAULT_FUNDS + + +def save_funds(funds): + with open(SINKING_FILE, "w") as f: + json.dump(funds, f, indent=2) + + +def months_until(target_date_str): + try: + target = date.fromisoformat(target_date_str) + today = date.today() + months = (target.year - today.year) * 12 + (target.month - today.month) + return max(months, 1) + except Exception: + return None + + +def monthly_needed(balance, goal, target_date_str): + m = months_until(target_date_str) + if not m: + return None + remaining = max(goal - balance, 0) + return round(remaining / m, 2) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6e71d1e --- /dev/null +++ b/static/style.css @@ -0,0 +1,1737 @@ +/* ── Fonts & Reset ── */ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Serif+Display&display=swap'); + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --cream: #F2EBE3; + --warm-white: #FAF6F2; + --soft-brown: #B8957A; + --text-dark: #3D2B1F; + --text-mid: #7A5C48; + --text-light: #B09080; + --border: #E0D0C4; + --shadow: rgba(80, 50, 30, 0.1); + + --sage: #A8C5A0; + --blush: #D4B8C5; + --dusty-blue: #A0B8C5; + --terracotta: #C5A0A0; + --lavender: #B4A0C5; + --warm-tan: #C5BBA0; + --moss: #B4C5A0; + --mauve: #C5A8B4; + + --radius: 12px; + --radius-sm: 8px; +} + +body { + font-family: 'DM Sans', sans-serif; + background: var(--cream); + color: var(--text-dark); + min-height: 100vh; +} + +/* ── Nav ── */ +nav { + background: #EAE0D8; + border-bottom: 1px solid var(--border); + padding: 0.6rem 2rem 0; + display: flex; + flex-direction: column; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-brand { + font-family: 'DM Serif Display', serif; + font-size: 1.15rem; + color: var(--text-dark); + letter-spacing: 0.01em; + white-space: nowrap; + padding-bottom: 0.4rem; +} + +.nav-links { + display: flex; + gap: 0.25rem; + overflow-x: auto; + border-top: 1px solid var(--border); + width: 100%; + justify-content: center; + padding: 0.3rem 0; +} + +.nav-links::-webkit-scrollbar { display: none; } + +.nav-links a { + text-decoration: none; + color: var(--text-mid); + font-size: 0.9rem; + font-weight: 500; + padding: 0.4rem 0.85rem; + border-radius: var(--radius-sm); + transition: background 0.15s, color 0.15s; +} + +.nav-links a:hover { + background: var(--cream); + color: var(--text-dark); +} + +/* ── Main ── */ +main { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +/* ── Page header ── */ +.page-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.75rem; +} + +.page-header h1 { + font-family: 'DM Serif Display', serif; + font-size: 1.9rem; + font-weight: 400; + color: var(--text-dark); +} + +.badge { + background: var(--blush); + color: var(--text-dark); + font-size: 0.8rem; + font-weight: 600; + padding: 0.25rem 0.75rem; + border-radius: 999px; +} + +/* ── Alert banner ── */ +.alert-banner { + background: #EDF4EC; + border: 1px solid #C5DCC2; + color: var(--text-dark); + padding: 0.9rem 1.25rem; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.alert-error { + background: #F4EDEC; + border-color: #DCC2C5; +} + +/* ── Cards ── */ +.card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem 1.5rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.card-label { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-light); + margin-bottom: 0.4rem; +} + +.card-value { + font-family: 'DM Serif Display', serif; + font-size: 2rem; + color: var(--text-dark); +} + +.card-wide { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: 0 2px 8px var(--shadow); + margin-bottom: 1.5rem; +} + +.card-wide h2 { + font-family: 'DM Serif Display', serif; + font-weight: 400; + font-size: 1.4rem; + margin-bottom: 0.5rem; +} + +/* ── Sections ── */ +.section { + margin-bottom: 2.5rem; +} + +.section h2 { + font-family: 'DM Serif Display', serif; + font-weight: 400; + font-size: 1.4rem; + margin-bottom: 1rem; + color: var(--text-dark); +} + +/* ── Category bars ── */ +.category-bars { display: flex; flex-direction: column; gap: 0.6rem; } + +.category-bar-row { + display: grid; + grid-template-columns: 130px 1fr 80px; + align-items: center; + gap: 0.75rem; +} + +.category-bar-label { + font-size: 0.88rem; + font-weight: 500; + color: var(--text-mid); + text-align: right; +} + +.category-bar-track { + background: var(--border); + border-radius: 999px; + height: 10px; + overflow: hidden; +} + +.category-bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.4s ease; + opacity: 0.85; +} + +.category-bar-amount { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-dark); +} + +/* ── Tables ── */ +.txn-table { + width: 100%; + border-collapse: collapse; + background: var(--warm-white); + border-radius: var(--radius); + overflow: hidden; + box-shadow: 0 2px 8px var(--shadow); + font-size: 0.9rem; +} + +.txn-table th { + background: var(--cream); + text-align: left; + padding: 0.75rem 1rem; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-light); + border-bottom: 1px solid var(--border); +} + +.txn-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + color: var(--text-dark); +} + +.txn-table tr:last-child td { border-bottom: none; } +.txn-table tr:hover td { background: #FDFAF7; } + +.amount { font-weight: 600; font-variant-numeric: tabular-nums; } +.muted { color: var(--text-light); font-size: 0.85rem; } +.edit-link { color: var(--text-mid); font-size: 0.85rem; text-decoration: none; } +.edit-link:hover { color: var(--text-dark); text-decoration: underline; } + +/* ── Tags ── */ +.tag { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + color: #FAF6F2; + opacity: 0.9; +} + +/* ── Buttons ── */ +.btn { + display: inline-block; + padding: 0.55rem 1.25rem; + border-radius: var(--radius-sm); + font-family: 'DM Sans', sans-serif; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + border: none; + text-decoration: none; + transition: opacity 0.15s, transform 0.1s; +} + +.btn:hover { opacity: 0.85; transform: translateY(-1px); } +.btn:active { transform: translateY(0); } + +.btn-primary { + background: #A8C5A0; + color: #FAF6F2; +} + +.btn-accept { + background: #C5DCC2; + color: #FAF6F2; + padding: 0.3rem 0.85rem; + font-size: 0.85rem; +} + +.btn-ghost { + background: transparent; + color: var(--text-mid); + border: 1px solid var(--border); +} + +.btn-powder-blue { + background: #B0CDD6; + color: #FAF6F2; +} + +.btn-dusty-rose { + background: #D4B8C5; + color: #FAF6F2; +} + +.btn-moss { + background: #8FB8A0; + color: #FAF6F2; +} + +.btn-large { padding: 0.75rem 2rem; font-size: 1rem; } + +/* ── Forms ── */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-mid); + margin-bottom: 0.4rem; +} + +.optional { font-weight: 400; color: var(--text-light); } + +.form-group input[type="text"], +.form-group select { + width: 100%; + padding: 0.6rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'DM Sans', sans-serif; + font-size: 0.95rem; + color: var(--text-dark); + background: var(--cream); + transition: border-color 0.15s; +} + +.form-group input[type="text"]:focus, +.form-group select:focus { + outline: none; + border-color: var(--sage); +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 400; + color: var(--text-mid); + cursor: pointer; +} + +.form-actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 1.5rem; +} + +.helper-text { + color: var(--text-light); + font-size: 0.875rem; + margin-bottom: 1.5rem; +} + +/* ── Import form ── */ +.import-form { max-width: 480px; } + +.file-drop-area { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 2rem; + text-align: center; + background: var(--cream); + cursor: pointer; + transition: border-color 0.15s; +} + +.file-drop-area:hover { border-color: var(--sage); } + +.file-drop-area input[type="file"] { + display: block; + margin: 0 auto 0.5rem; +} + +.file-drop-area p { color: var(--text-light); font-size: 0.85rem; } +.file-types { font-size: 0.78rem !important; margin-top: 0.25rem; } + +.tips { + background: #FDFAF7; +} + +.tips h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--text-mid); +} + +.tips ul { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.tips li { + font-size: 0.88rem; + color: var(--text-mid); + padding-left: 1rem; + position: relative; +} + +.tips li::before { + content: "→"; + position: absolute; + left: 0; + color: var(--text-light); +} + +/* ── Review card ── */ +.review-card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: 0 2px 8px var(--shadow); + max-width: 680px; +} + +.review-meta { + display: flex; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.review-date { color: var(--text-light); font-size: 0.9rem; } +.review-account { color: var(--text-light); font-size: 0.9rem; } + +.review-description { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.review-amount { + font-family: 'DM Serif Display', serif; + font-size: 2rem; + color: var(--text-dark); + margin-bottom: 1.5rem; +} + +.suggestion-banner { + background: #EDF4EC; + border: 1px solid #C5DCC2; + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +/* ── Category grid ── */ +.category-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.5rem; +} + +.cat-option { + cursor: pointer; + display: block; +} + +.cat-option input[type="radio"] { display: none; } + +.cat-option span { + display: block; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 500; + text-align: center; + background: var(--border); + color: var(--text-mid); + border: 2px solid transparent; + transition: background 0.15s, border-color 0.15s; +} + +.cat-option input:checked + span { + background: var(--cat-color, var(--sage)); + color: var(--text-dark); + border-color: color-mix(in srgb, var(--cat-color, var(--sage)) 70%, #555); +} + +.cat-option:hover span { + background: color-mix(in srgb, var(--cat-color, var(--border)) 40%, white); +} + +/* ── Summary grid ── */ +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; +} + +.summary-card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem 1.5rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.summary-cat-name { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-light); + margin-bottom: 0.25rem; +} + +.summary-cat-total { + font-family: 'DM Serif Display', serif; + font-size: 1.6rem; + margin-bottom: 0.75rem; +} + +.subcat-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.subcat-list li { + display: flex; + justify-content: space-between; + font-size: 0.82rem; + color: var(--text-mid); + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--border); +} + +.subcat-list li:last-child { border-bottom: none; } + +/* ── Empty state ── */ +.empty-state { + text-align: center; + padding: 3rem 2rem; + color: var(--text-light); +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.empty-state h2 { + font-family: 'DM Serif Display', serif; + font-weight: 400; + font-size: 1.5rem; + color: var(--text-mid); + margin-bottom: 0.5rem; +} + +.empty-state p { margin-bottom: 1.25rem; } +.empty-state a { color: var(--text-dark); } + +/* ── Calendar ── */ +.cal-wrap { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + box-shadow: 0 2px 8px var(--shadow); + margin-bottom: 1.5rem; +} + +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +.cal-header { + background: var(--cream); + text-align: center; + padding: 0.6rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-light); + border-bottom: 1px solid var(--border); +} + +.cal-cell { + min-height: 100px; + padding: 0.5rem; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.cal-cell:nth-child(7n) { border-right: none; } +.cal-empty { background: #FDFAF7; } + +.cal-today { background: #F0F7EF; } + +.cal-day-num { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-light); + margin-bottom: 0.35rem; +} + +.cal-today .cal-day-num { + color: var(--text-dark); + font-weight: 700; +} + +.cal-bill { + border-radius: 4px; + padding: 0.2rem 0.4rem; + margin-bottom: 0.2rem; + font-size: 0.72rem; + line-height: 1.3; + opacity: 0.88; +} + +.cal-bill-name { display: block; font-weight: 600; color: #FAF6F2; } +.cal-bill-amt { display: block; color: #FAF6F2; } + +.cal-legend { + display: flex; + flex-wrap: wrap; + gap: 0.6rem 1.2rem; + margin-bottom: 1.25rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--text-mid); +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + opacity: 0.85; +} + +.cal-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.month-nav { + display: flex; + gap: 0.5rem; + margin-left: auto; +} + +/* ── Bills form ── */ +.bill-form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem 1.25rem; + margin-bottom: 1.25rem; +} + +/* ── Weekly overview ── */ +.week-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.week-tab { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 1.25rem; + font-family: 'DM Sans', sans-serif; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-mid); + cursor: pointer; + text-align: center; + line-height: 1.4; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.week-tab span { + display: block; + font-size: 0.75rem; + font-weight: 400; + color: var(--text-light); +} + +.week-tab.active { + background: var(--sage); + border-color: var(--sage); + color: var(--text-dark); +} + +.week-panel { display: none; } +.week-panel.active { display: block; } + +.budget-section { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 1.25rem; + overflow: hidden; + box-shadow: 0 2px 8px var(--shadow); +} + +.budget-section-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1.25rem; + border-bottom: 1px solid var(--border); + border-left: 4px solid var(--sage); + background: var(--cream); +} + +.budget-section-header h3 { + font-family: 'DM Serif Display', serif; + font-weight: 400; + font-size: 1.1rem; +} + +.budget-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.budget-table th { + text-align: left; + padding: 0.55rem 1rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-light); + border-bottom: 1px solid var(--border); +} + +.budget-table td { + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.budget-table tbody tr:last-child td { border-bottom: none; } +.budget-table tfoot td { + font-weight: 600; + background: var(--cream); + border-top: 1px solid var(--border); + border-bottom: none; +} + +.total-row td { font-weight: 600; } + +.budget-input { + width: 100%; + padding: 0.35rem 0.6rem 0.35rem 1.3rem; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-family: 'DM Sans', sans-serif; + font-size: 0.9rem; + background: transparent; + color: var(--text-dark); + transition: border-color 0.15s, background 0.15s; +} + +/* Dollar sign wrapper — added via JS for number inputs */ +.dollar-wrap { + position: relative; + display: inline-flex; + align-items: center; + width: 100%; +} +.dollar-wrap::before { + content: '$'; + position: absolute; + left: 0.45rem; + color: var(--text-light); + font-size: 0.88rem; + pointer-events: none; + z-index: 1; +} +.dollar-wrap .budget-input { + padding-left: 1.3rem; +} + +/* For non-wrapped inputs that are inline (not full-width) */ +input[type="number"].budget-input:not(.no-dollar) { + padding-left: 1.3rem; +} + +.budget-input:hover { border-color: var(--border); } + +.budget-input:focus { + outline: none; + border-color: var(--sage); + background: var(--cream); +} + +.budget-input-name { color: var(--text-mid); } + +.leftover-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: #EDF4EC; + border: 1px solid #C5DCC2; + border-radius: var(--radius); + font-weight: 600; + font-size: 1rem; + margin-bottom: 2rem; +} + +.leftover-negative { + background: #F4EDEC; + border-color: #DCC2C5; +} + +.leftover-amount { + font-family: 'DM Serif Display', serif; + font-size: 1.5rem; +} + +/* ── Toggle buttons ── */ +.toggle-btn { + padding: 0.25rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--cream); + color: var(--text-light); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + font-family: 'DM Sans', sans-serif; + transition: background 0.15s, color 0.15s; +} + +.toggle-btn.toggle-on { + background: #C5DCC2; + border-color: #A8C5A0; + color: var(--text-dark); +} + +/* ── Reminder bar ── */ +.reminder-bar { + display: flex; + align-items: center; + gap: 1rem; + background: #F5F1EC; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem 1.25rem; + margin-bottom: 1.5rem; + font-size: 0.9rem; + color: var(--text-mid); + flex-wrap: wrap; +} + +.reminder-result { + font-size: 0.85rem; + color: var(--text-mid); + font-style: italic; +} + +/* ── Income entry ── */ +.income-friday-tag { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + background: #EDF4EC; + color: #7A9E78; + border-radius: 999px; + padding: 0.1rem 0.5rem; + margin-left: 0.4rem; + vertical-align: middle; +} + +.income-actual-wrap { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.income-logged-badge { + font-size: 0.75rem; + font-weight: 700; + color: #7A9E78; + white-space: nowrap; +} + +/* ── Income Stream Cards (dashboard) ── */ +.income-stream-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; +} + +.income-stream-card { + background: var(--warm-white); + border-radius: var(--radius-sm); + padding: 0.85rem 1rem; + border: 1px solid var(--border); +} + +.income-stream-name { + font-weight: 600; + font-size: 0.88rem; + margin-bottom: 0.2rem; +} + +.income-stream-owner { + font-size: 0.75rem; + margin-bottom: 0.5rem; +} + +.income-stream-amount { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-dark); +} + +/* ── Debt Tracker ── */ +.debt-grand-total { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.debt-grand-item { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.1rem 1.25rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.debt-grand-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-light); + margin-bottom: 0.3rem; +} + +.debt-grand-value { + font-family: 'DM Serif Display', serif; + font-size: 1.6rem; + color: var(--text-dark); +} + +.debt-grand-value.accent { color: #7A9E78; } + +.debt-cat-header { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.debt-cat-header h2 { + font-family: 'DM Serif Display', serif; + font-weight: 400; + font-size: 1.4rem; +} + +.debt-cat-totals { + display: flex; + gap: 1.25rem; + font-size: 0.85rem; + color: var(--text-mid); + flex-wrap: wrap; +} + +.debt-cat-totals .accent { color: #7A9E78; } + +.debt-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.debt-card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.debt-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + gap: 0.5rem; +} + +.debt-card-name { + font-weight: 600; + font-size: 1rem; + color: var(--text-dark); +} + +.debt-card-notes { + font-size: 0.78rem; + color: var(--text-light); + margin-top: 0.2rem; +} + +.debt-card-badges { + display: flex; + gap: 0.4rem; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.owner-tag { + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + color: #FAF6F2; +} + +.apr-tag { + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + background: var(--border); + color: var(--text-mid); +} + +.debt-card-balance { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.debt-balance-label { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-light); +} + +.debt-balance-value { + font-family: 'DM Serif Display', serif; + font-size: 1.5rem; + color: var(--text-dark); +} + +.payoff-estimate { + font-size: 0.8rem; + color: #7A9E78; + margin-bottom: 0.75rem; +} + +.debt-month-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +.debt-month-table td { + padding: 0.35rem 0; + border-bottom: 1px solid var(--border); + color: var(--text-mid); +} + +.debt-month-table td:first-child { width: 60%; } +.debt-month-table tr:last-child td { border-bottom: none; } + +.debt-extra-row td { + font-weight: 600; + color: var(--text-dark) !important; + padding-top: 0.5rem; +} + +.accent { color: #7A9E78; } + +.debt-delete-btn { + background: none; + border: none; + font-size: 0.78rem; + color: var(--text-light); + cursor: pointer; + padding: 0; + font-family: 'DM Sans', sans-serif; +} + +.debt-delete-btn:hover { color: #C5A0A0; } + +.add-debt-details summary { list-style: none; } +.add-debt-details summary::-webkit-details-marker { display: none; } + +/* ── BNPL Progress ── */ +.bnpl-progress-wrap { margin-bottom: 0.75rem; } + +.bnpl-progress-bar { + background: var(--border); + border-radius: 999px; + height: 8px; + overflow: hidden; + margin-bottom: 0.3rem; +} + +.bnpl-progress-fill { + height: 100%; + background: #A8C5A0; + border-radius: 999px; + transition: width 0.4s ease; +} + +.bnpl-progress-label { + font-size: 0.78rem; + color: var(--text-light); +} + +/* ── Strategy Page ── */ +.strategy-compare { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.strategy-card { + flex: 1; + min-width: 220px; + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.strategy-avalanche { border-top: 4px solid #A8C5A0; } +.strategy-snowball { border-top: 4px solid #D4B8C5; } + +.strategy-label { + font-family: 'DM Serif Display', serif; + font-size: 1.2rem; + margin-bottom: 0.2rem; +} + +.strategy-subtitle { + font-size: 0.82rem; + color: var(--text-light); + margin-bottom: 1rem; +} + +.strategy-stat { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 0.9rem; + color: var(--text-mid); + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} + +.strategy-stat:last-of-type { border-bottom: none; } +.strategy-stat strong { font-size: 1rem; color: var(--text-dark); } + +.strategy-badge { + margin-top: 1rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-mid); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.strategy-vs { + font-family: 'DM Serif Display', serif; + font-size: 1.4rem; + color: var(--text-light); + flex-shrink: 0; +} + +.strategy-insight { + display: flex; + gap: 2rem; + background: #EDF4EC; + border: 1px solid #C5DCC2; + border-radius: var(--radius); + padding: 0.9rem 1.25rem; + font-size: 0.9rem; + color: var(--text-mid); + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.strategy-insight strong { color: var(--text-dark); } + +.strategy-tables { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.strategy-table-wrap h3 { + font-family: 'DM Serif Display', serif; + font-weight: 400; + font-size: 1.1rem; + margin-bottom: 0.75rem; +} + +/* ── Sinking Funds ── */ +.sinking-total-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem 1.75rem; + margin-bottom: 1.75rem; + box-shadow: 0 2px 8px var(--shadow); + flex-wrap: wrap; + gap: 0.5rem; +} + +.sinking-total-label { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-light); + margin-bottom: 0.25rem; +} + +.sinking-total-amount { + font-family: 'DM Serif Display', serif; + font-size: 2rem; + color: var(--text-dark); +} + +.sinking-total-note { + font-size: 0.85rem; + color: var(--text-light); + font-style: italic; +} + +.sinking-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.sinking-card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: 0 2px 8px var(--shadow); + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.sinking-add-card { + border-top: 4px solid var(--border); + background: #FDFAF7; +} + +.sinking-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sinking-card-name { + font-weight: 600; + font-size: 1rem; +} + +.sinking-type-badge { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: var(--border); + color: var(--text-light); +} + +.badge-goal { + background: #E8E0F0; + color: #7A6A8A; +} + +.sinking-notes { + font-size: 0.78rem; + color: var(--text-light); + font-style: italic; +} + +.sinking-balance { + font-family: 'DM Serif Display', serif; + font-size: 1.75rem; + color: var(--text-dark); +} + +.sinking-progress-wrap { margin: 0.25rem 0; } + +.sinking-progress-bar { + background: var(--border); + border-radius: 999px; + height: 8px; + overflow: hidden; + margin-bottom: 0.3rem; +} + +.sinking-progress-fill { + height: 100%; + border-radius: 999px; + opacity: 0.85; + transition: width 0.4s ease; +} + +.sinking-progress-label { + font-size: 0.78rem; + color: var(--text-light); +} + +.sinking-monthly-needed { + font-size: 0.85rem; + color: var(--text-mid); + background: var(--cream); + border-radius: var(--radius-sm); + padding: 0.4rem 0.7rem; +} + +.sinking-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.25rem; +} + +.sinking-action-form { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.sinking-amount-input { + width: 90px; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'DM Sans', sans-serif; + font-size: 0.85rem; + background: var(--cream); +} + +.sinking-edit-details { margin-top: 0.25rem; } + +.sinking-edit-details summary { list-style: none; } +.sinking-edit-details summary::-webkit-details-marker { display: none; } + +.sinking-edit-toggle { + font-size: 0.78rem; + color: var(--text-light); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.sinking-edit-form { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.sinking-edit-form .form-group { margin-bottom: 0; } + +/* ── Mortgage ── */ +.mortgage-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.mortgage-strategies { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; +} + +.mortgage-strategy-card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.4rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.mortgage-strategy-empty { + background: #FDFAF7; + display: flex; + flex-direction: column; + justify-content: center; +} + +.amortization-details { margin-top: 0.5rem; } +.amortization-details summary { list-style: none; } +.amortization-details summary::-webkit-details-marker { display: none; } + +.amortization-toggle { + font-size: 1rem; + font-weight: 600; + color: var(--text-mid); + cursor: pointer; + padding: 0.75rem 0; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.amortization-toggle:hover { color: var(--text-dark); } + +.amortization-wrap { + max-height: 480px; + overflow-y: auto; + margin-top: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.amortization-table { margin: 0; } + +.amort-interest-heavy td { color: var(--text-mid); } +.amort-interest { color: #C5A0A0; } + +/* ── Assets ── */ +.asset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.asset-card { + background: var(--warm-white); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: 0 2px 8px var(--shadow); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.asset-flip-card { border-top: 4px solid #B0CDD6; } + +.asset-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.asset-car-name { + font-weight: 600; + font-size: 1rem; + color: var(--text-dark); +} + +.asset-car-trim { + font-size: 0.82rem; + margin-top: 0.15rem; +} + +.asset-stat-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + padding: 0.75rem 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.asset-stat { text-align: center; } + +.asset-stat-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-light); + margin-bottom: 0.2rem; +} + +.asset-stat-value { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-dark); +} + +.flip-badge { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 0.2rem 0.6rem; + border-radius: 999px; + background: #B0CDD6; + color: var(--text-dark); +} + +.asset-details summary { list-style: none; } +.asset-details summary::-webkit-details-marker { display: none; } + +.asset-details-toggle { + font-size: 0.82rem; + color: var(--text-light); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.asset-details-toggle:hover { color: var(--text-dark); } + +.asset-log-form { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.asset-log-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.5rem 0.75rem; + margin-bottom: 0.75rem; +} + +.asset-log-grid .form-group { margin-bottom: 0; } + +.asset-log-section-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-light); + margin: 0.5rem 0 0.25rem; +} + +.asset-log-entry { + display: flex; + gap: 0.75rem; + font-size: 0.82rem; + padding: 0.3rem 0; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.asset-log-entry:last-child { border-bottom: none; } + +.text-red { color: #C5A0A0; } + +.asset-subheading { + font-size: 1rem; + font-weight: 600; + color: var(--text-mid); + margin-bottom: 0.75rem; +} + +.asset-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.asset-type-btn { + display: block; + background: var(--cream); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + text-align: center; + transition: background 0.15s; +} + +.asset-type-btn:hover { background: var(--border); } + +/* ── Debt Summary Table ── */ +.debt-summary-block { + background: var(--surface, #fff); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 1.5rem; + overflow: hidden; +} +.debt-summary-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +.debt-summary-table th { + background: var(--cream, #faf7f2); + padding: 0.6rem 1rem; + text-align: left; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-light); + border-bottom: 1px solid var(--border); +} +.debt-summary-table td { + padding: 0.55rem 1rem; + border-bottom: 1px solid var(--border); +} +.debt-summary-table tfoot tr td { + background: var(--cream, #faf7f2); + border-top: 2px solid var(--border); + border-bottom: none; +} +.debt-collapse-arrow { + font-size: 0.85rem; + color: var(--text-light); + transition: transform 0.15s; + width: 1rem; + display: inline-block; +} + +/* ── Net Worth ── */ +.net-worth-hero { + text-align: center; + padding: 2rem 1rem; + border-radius: var(--radius); + margin-bottom: 0.5rem; + background: linear-gradient(135deg, #f0f7f0 0%, #e8f4e8 100%); + border: 1px solid #b8d8b8; +} +.net-worth-hero.net-worth-negative { + background: linear-gradient(135deg, #fdf0f0 0%, #f9e8e8 100%); + border-color: #d8b8b8; +} +.net-worth-label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-light); + margin-bottom: 0.4rem; +} +.net-worth-number { + font-family: 'DM Serif Display', serif; + font-size: 2.8rem; + color: #3a7a3a; + margin-bottom: 0.5rem; +} +.net-worth-hero.net-worth-negative .net-worth-number { color: #a04040; } +.net-worth-sub { + font-size: 0.9rem; + color: var(--text-light); +} + +/* ── Responsive ── */ +@media (max-width: 600px) { + .card-grid { grid-template-columns: 1fr; } + .category-bar-row { grid-template-columns: 90px 1fr 60px; } + nav { padding: 0 1rem; } + main { padding: 1.25rem 1rem 3rem; } + .cal-cell { min-height: 60px; } + .week-tabs { flex-direction: column; } +} diff --git a/templates/assets.html b/templates/assets.html new file mode 100644 index 0000000..3d2c379 --- /dev/null +++ b/templates/assets.html @@ -0,0 +1,486 @@ +{% extends "base.html" %} +{% block content %} + + + + +{% if net_worth_assets %} +
+
+
Total Tracked Asset Value
+
${{ "{:,.2f}".format(net_worth_assets) }}
+
+
Based on estimated / current values entered below
+
+{% endif %} + + +
+ + Add Asset +
+

What type of asset?

+
+ + +
+ 🚗 Vehicle +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+ 📈 Investments +
+ +
+
+
+
+
+
+
+ +
+
+ + +
+ 🏠 Real Estate +
+ +
+
+
+
+
+
+
+
+ +
+
+ + +
+ 🔧 Equipment +
+ +
+
+
+
+
+
+
+
+ +
+
+ + +
+ ✨ Other Valuables +
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ + +
+

Vehicles

+ +

Personal

+
+ {% for car in personal %} +
+
+
+
{{ car.display_name }}
+
{{ car.trim }}{% if car.color %} · {{ car.color }}{% endif %}
+
+ {{ car.owner }} +
+
+
Total Spent
${{ car.total_spent | money }}
+
Est. Value
{% if car.estimated_value %}${{ "{:,.0f}".format(car.estimated_value) }}{% else %}{% endif %}
+
Gas Logs
{{ car.gas_log | length }}
+
Service Logs
{{ car.maintenance_log | length }}
+
+
+ Log Gas Fill-up +
+ +
+
+
+
+
+
+ +
+
+
+ Log Maintenance / Repair +
+ +
+
+
+
+
+
+ +
+
+ {% set recent_gas = car.gas_log[-3:] | reverse | list %} + {% set recent_maint = car.maintenance_log[-3:] | reverse | list %} + {% if recent_gas or recent_maint %} +
Recent Activity + {% if recent_gas %}{% for e in recent_gas %}
{{ e.date }}${{ e.amount | money }}{% if e.gallons %}{{ e.gallons }} gal{% endif %} {{ e.notes }}
{% endfor %}{% endif %} + {% if recent_maint %}{% for e in recent_maint %}
{{ e.date }}${{ e.amount | money }}{{ e.get('description','') }} {{ e.notes }}
{% endfor %}{% endif %} +
+ {% endif %} +
Edit Details +
+
+
+
+
+
+
+
+
+ +
+
+
+ {% endfor %} +
+ + {% if active_flips %} +

Flip Inventory

+
+
+ + Add Flip Car +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ {% for flip in active_flips %} +
+
+
{{ flip.display_name }}
{{ flip.trim }}{% if flip.color %} · {{ flip.color }}{% endif %}
+
+ Flip + +
+
+
+
Purchased
${{ "{:,.0f}".format(flip.purchase_price) }}
+
Total In
${{ "{:,.0f}".format(flip.total_invested) }}
+
Expenses
{{ flip.expense_log | length }}
+
+
+ Log Personal Expense +
+
+
+
+
+
+ +
+
+ {% if flip.expense_log %}
Expense Log{% for e in flip.expense_log | reverse %}
{{ e.date }}${{ e.amount | money }}{{ e.description }}
{% endfor %}
{% endif %} +
✓ Mark as Sold +
+
+
+
+
+ +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +

Flip Inventory

+
+ + Add Flip Car +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

No active flips.

+ {% endif %} + + {% if sold_flips %} +

Sold Flips

+ + + + {% for flip in sold_flips %} + {% set profit = flip.profit %} + + + + + + + + + {% endfor %} + +
CarBought ForTotal InvestedSold ForProfit / LossSold
{{ flip.display_name }}{% if flip.color %} {{ flip.color }}{% endif %}${{ "{:,.2f}".format(flip.purchase_price) }}${{ "{:,.2f}".format(flip.total_invested) }}${{ "{:,.2f}".format(flip.sale_price) }}{% if profit is not none %}{{ '+' if profit > 0 else '' }}${{ "{:,.2f}".format(profit) }}{% else %}—{% endif %}{{ flip.sold_date }}
+ {% endif %} +
+ + +
+

📈 How Investment Tracking Works

+

Add each investment account (Roth IRA, 401k, brokerage, etc.) using the + Add Asset button above and select Investments.

+

Once added, each card has an editable Current Value field. Update it whenever you check your account — once a month is a good habit. Every time you update the value, the app logs a snapshot with today's date.

+

After two or more updates on different days, a growth chart will appear on the card automatically, showing your balance over time in green (growth) or red (decline) with the total change displayed.

+
+ + +{% for cat in asset_categories %} +
+

{{ cat }}

+ {% set cat_assets = by_category[cat] %} + {% if cat_assets %} +
+ {% for a in cat_assets %} +
+
+
{{ a.name }}
{% if a.notes %}
{{ a.notes }}
{% endif %}
+ {{ a.owner }} +
+
+
Paid
{% if a.purchase_price %}${{ "{:,.0f}".format(a.purchase_price) }}{% else %}{% endif %}
+
+
Current Value
+
+ $ +
+
+
Date
{{ a.purchase_date or '—' }}
+
+ {% if a.value_history and a.value_history | length > 1 %} +
+
+ Value History + {% set first = a.value_history[0].value | float %} + {% set last = a.value_history[-1].value | float %} + {% set change = last - first %} + {% if change >= 0 %} + ▲ ${{ change | money }} + {% else %} + ▼ ${{ change | abs | money }} + {% endif %} +
+ + +
+ {% elif a.value_history and a.value_history | length == 1 %} +
Update value again next month to start seeing growth chart.
+ {% endif %} + +
+ +
+
+ {% endfor %} +
+ {% else %} +

No {{ cat | lower }} added yet.

+ {% endif %} +
+{% endfor %} + + + + + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b5b83e6 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,71 @@ + + + + + + 🌙 Moon Household Budget + + + + +
+ {% if unreviewed_count > 0 %} +
+ You have {{ unreviewed_count }} transaction{{ 's' if unreviewed_count != 1 }} waiting to be categorized. + Review Now +
+ {% endif %} + {% block content %}{% endblock %} +
+ + + diff --git a/templates/bills.html b/templates/bills.html new file mode 100644 index 0000000..4105045 --- /dev/null +++ b/templates/bills.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} +{% block content %} + + +
+

Add a Bill

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +{% if bills %} +
+
+

Your Bills ({{ bills | length }} total)

+ +
+ + + + + + + + + + + + + + + {% for b in bills | sort(attribute='due_day') %} + + + + + + + + + + + {% endfor %} + +
Due DayNameAmountCategoryAutopayRemind MeNotes
{{ b.due_day }}{{ b.name }}{% if b.amount %}${{ b.amount | money }}{% else %}varies{% endif %} + {% if b.category %} + + {{ b.category }}{% if b.subcategory %} › {{ b.subcategory }}{% endif %} + + {% endif %} + + + + + {{ b.notes }} +
+ +
+
+
+{% else %} +
+

No bills yet. Add your first one above.

+
+{% endif %} + +
+ Run reminders now to test your email & Mac notifications: + + +
+ + +{% endblock %} diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 0000000..276f751 --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+ {% for day_name in ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'] %} +
{{ day_name }}
+ {% endfor %} + + {% for week in grid %} + {% for day in week %} + {% if day is none %} +
+ {% else %} +
+
{{ day }}
+ {% for bill in bills_by_day.get(day, []) %} +
+ {{ bill.name }} + {% if bill.amount %}${{ bill.amount | money }}{% endif %} + {% if bill.get('paid') %}{% endif %} +
+ {% endfor %} +
+ {% endif %} + {% endfor %} + {% endfor %} +
+
+ +
+ {% for cat, color in colors.items() %} + + {{ cat }} + + {% endfor %} +
+ + +{% endblock %} diff --git a/templates/debts.html b/templates/debts.html new file mode 100644 index 0000000..a64777f --- /dev/null +++ b/templates/debts.html @@ -0,0 +1,774 @@ +{% extends "base.html" %} +{% block content %} + + + +
+ +
+ + Add Credit Card +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + Add Medical Debt +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + Add Affirm / Klarna / BNPL Plan +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ 📋 Add Card by Pasting Statement +
+

Fill in the card name and owner, paste your statement text, and we'll create the card and fill in all the numbers at once.

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + {% for cat in debt_categories %} + {% set ct = totals[cat] %} + {% if by_category[cat] %} + + + + + + + + {% endif %} + {% endfor %} + + + + + + + + + + +
CategoryBalanceMinimumsPaid This MonthExtra
{{ cat }}${{ ct.balance | money }}${{ ct.minimum | money }}${{ ct.paid | money }}${{ ct.extra | money }}
Grand Total${{ grand.balance | money }}${{ grand.minimum | money }}${{ grand.paid | money }}${{ grand.extra | money }}
+
+ + +{% if dti is not none %} +{% if dti <= 20 %} + {% set dti_color = "#5A9E68" %}{% set dti_label = "Healthy" %}{% set dti_tip = "Under 20% is excellent." %} +{% elif dti <= 36 %} + {% set dti_color = "#D4A96A" %}{% set dti_label = "Manageable" %}{% set dti_tip = "20–36% is average. Keep an eye on it." %} +{% elif dti <= 50 %} + {% set dti_color = "#E8956A" %}{% set dti_label = "High" %}{% set dti_tip = "36–50% is high. Prioritize paying down debt." %} +{% else %} + {% set dti_color = "#C57A7A" %}{% set dti_label = "Critical" %}{% set dti_tip = "Over 50% is a red flag. Focus on reducing debt ASAP." %} +{% endif %} +
+
+
Debt-to-Income Ratio
+
{{ dti }}%
+
+
+
+ {{ dti_label }} + ${{ "%.0f" | format(grand.minimum) }} min / ${{ "%.0f" | format(monthly_income) }} income +
+
+
+
+
{{ dti_tip }}
+
+
+{% else %} +
+ DTI ratio unavailable — log this month's income to see your debt-to-income ratio. +
+{% endif %} + + +
+ +
+ + +{% for cat in debt_categories %} +{% set debts = by_category[cat] %} +{% set cat_totals = totals[cat] %} +{% if debts %} +
+
+
+ +

{{ cat }}

+
+
+ Balance: ${{ cat_totals.balance | money }} + Minimums: ${{ cat_totals.minimum | money }} + Paid: ${{ cat_totals.paid | money }} + Extra: ${{ cat_totals.extra | money }} +
+
+ +
+ {% for d in debts %} +
+
+
+
{{ d.name }}
+ {% if d.notes %}
{{ d.notes }}
{% endif %} +
+
+ {{ d.owner }} + {% if d.apr %}{{ d.apr }}% APR{% endif %} + {% if d.get('is_bnpl') %} + + {% else %} + + {% endif %} +
+
+ +
+ Balance + ${{ d.ending | money }} +
+ {% if d.credit_limit %} + {% set available = d.credit_limit - d.ending %} + {% set util_pct = [(d.ending / d.credit_limit * 100), 100] | min | int %} +
+
+ Available credit + + ${{ available | money }} of ${{ d.credit_limit | money }} + +
+
+
+
+
{{ util_pct }}% utilized
+
+ {% endif %} + + {% if d.payoff_str and not d.get('is_bnpl') %} +
+
+ Payoff projection + {{ d.payoff_str }} +
+ {% if d.months_left and d.months_left <= 36 %} + {% set payoff_pct = [100 - (d.months_left / 36 * 100), 5] | max | int %} +
+
+
+ {% endif %} +
+ {{ d.months_left }} month{{ 's' if d.months_left != 1 }} at minimum payments + {% if d.apr %} · {{ d.apr }}% APR{% endif %} +
+
+ {% endif %} + + + {% if not d.get('is_bnpl') %} +
+ + {% if d.get('statement_pending') %} + numbers may not reflect current statement + {% endif %} +
+ {% endif %} + + {% if d.get('is_bnpl') %} + {% set pct = ((d.payments_made / d.total_payments) * 100) | int if d.total_payments else 0 %} + {% set remaining_payments = d.total_payments - d.payments_made %} + {% set payoff_str, _ = bnpl_payoff_date(d.payments_made, d.total_payments, d.frequency_days) %} +
+
+
+
+
{{ d.payments_made }} of {{ d.total_payments }} payments made
+
+ + + + + {% if payoff_str %}{% endif %} +
Payment Amount${{ d.payment_amount | money }}
Payments Remaining{{ remaining_payments }}
Still Owed${{ d.current_balance | money }}
Payoff Date{{ payoff_str }}
+
+ +
+ {% else %} + + + + + + + + + + + + + + + + + + + + + + + {% set underpaid = d.payment > 0 and d.payment < d.minimum and not d.get('statement_pending') %} + + + +
Starting Balance
Interest Charged
New Charges
Minimum Due${{ d.minimum | money }}
Payment Made
{% if underpaid %}⚠️ Underpaid Minimum{% else %}Extra Paid Above Minimum{% endif %} + {% if underpaid %} + -${{ d.minimum - d.payment | money }} short + {% elif d.get('statement_pending') %} + + {% else %} + ${{ d.extra | money }} + {% endif %} +
+ {% endif %} + + {% if not d.get('is_bnpl') %} + + {% endif %} + +
+ +
+
+ {% endfor %} +
+
+{% endif %} +{% endfor %} + +{% if not (by_category.values() | map('list') | sum(start=[])) %} +
+

No debts added yet. Click "+ Add a Debt" above to get started.

+
+{% endif %} + + + + + + + + + + + + + +{% endblock %} diff --git a/templates/edit.html b/templates/edit.html new file mode 100644 index 0000000..6fcdd5a --- /dev/null +++ b/templates/edit.html @@ -0,0 +1,234 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+ {{ transaction.date }} + +
+
{{ transaction.description }}
+
${{ transaction.amount | money }}
+ +
+
+ +
+ {% for cat, subcats in categories.items() %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + Cancel +
+
+ + + + +
+
+ + + +
+ + +
+ + + + +
+
+ + + + +
+
+
+ + +{% endblock %} diff --git a/templates/import.html b/templates/import.html new file mode 100644 index 0000000..39cd69e --- /dev/null +++ b/templates/import.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block content %} + + +{% if message %} +
+ {{ message }} + {% if 'Imported' in message %} + Review Transactions + {% endif %} +
+{% endif %} + +
+

Upload a Bank or Credit Card Statement

+

Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.

+ +
+
+ + +
+ +
+ +
+ +

Click to choose a file, or drag and drop here

+

PDF or CSV

+
+
+ + +
+
+ +
+

How to export your statements

+
    +
  • WestConsin Credit Union: Log in at westconsin.org → select your account → look for a Download or Export button near the top of your transaction list → choose CSV or Excel format → pick your date range and download
  • +
  • Most other banks: Log in → Account Details or Transaction History → Export / Download → choose CSV
  • +
  • Affirm: affirm.com → Account → Payment History → Export (note: their export is often broken — manual entry may be needed)
  • +
  • Klarna: klarna.com → Purchases → Export CSV
  • +
  • Afterpay: afterpay.com → Orders → Export
  • +
+

If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.

+
+{% endblock %} diff --git a/templates/income.html b/templates/income.html new file mode 100644 index 0000000..c596ac0 --- /dev/null +++ b/templates/income.html @@ -0,0 +1,277 @@ +{% extends "base.html" %} +{% block content %} + + + + +
+
+
{{ month_name }} Total
+
${{ month_total | money }}
+
+
+
{{ year }} YTD Total
+
${{ ytd_total | money }}
+
+
+
Monthly Average
+
+ {% set months_with_income = ytd_by_month | length %} + ${{ (ytd_total / months_with_income if months_with_income else 0) | money }} +
+
+
+ + +
+
+
🧾 {{ year }} Tax Estimate — based on ${{ ytd_total | money }} YTD income
+
+ ~${{ tax_estimate.total | money }} owed  ·  + ${{ tax_set_aside | money }} set aside  ·  + {% if tax_still_needed > 0 %} + ${{ tax_still_needed | money }} still needed + {% else %} + fully covered ✓ + {% endif %} +
+
+ View Tax Details → +
+ + +
+
+

{{ month_name }} {{ year }} — by Stream

+
+ + + + + + {% for s in income_streams %} + + + + + + + + {% endfor %} + + + + + + + + + +
StreamOwnerThis MonthYTD
{{ s.name }}{{ s.owner }}${{ by_stream.get(s.name, 0) | money }}${{ ytd_by_stream.get(s.name, 0) | money }} + +
Total${{ month_total | money }}${{ ytd_total | money }}
+
+ + +{% if ytd_by_month %} +
+
+

{{ year }} — Month by Month

+
+
+ +
+ + + + {% for mk in sorted_months %} + {% set amt = ytd_by_month[mk] %} + + + + + {% endfor %} + + + + + + + +
MonthTotal Income
{{ mk }}${{ amt | money }}
YTD Total${{ ytd_total | money }}
+
+{% endif %} + + +{% if entries %} +
+
+

{{ month_name }} Entries

+
+ + + + {% for e in entries | sort(attribute='date', reverse=True) %} + + + + + + + + {% endfor %} + +
StreamAmountDate LoggedNote
{{ e.stream }}${{ e.amount | money }}{{ e.date }}{{ e.note or '—' }} +
+ + + +
+
+
+{% endif %} + + + + + + +
+ +
+ + +
+
+

Backfill Past Months

+
+

Enter totals for any month you haven't logged yet. Leave blank to skip. Existing entries won't be duplicated.

+
+ +
+ + + + + {% for s in income_streams %} + + {% endfor %} + + + + {% for m in range(1, 13) %} + {% set mk = '%04d-%02d' | format(year, m) %} + {% set mn = month_names[m] %} + {% set is_current = (m == month) %} + + + {% for s in income_streams %} + + {% endfor %} + + {% endfor %} + +
Month + {{ s.name }}
+ {{ s.owner }} +
+ {{ mn }} + {% if is_current %} (current){% endif %} + {% if ytd_by_month.get(mk) %} + ✓ ${{ "%.0f"|format(ytd_by_month[mk]) }} + {% endif %} + + +
+
+
+ +
+
+
+ + + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b5c9a74 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,385 @@ +{% extends "base.html" %} +{% block content %} + + + + +
+
+
Income
+
${{ total_income | money }}
+
+
+
Spent
+
${{ (total_spending + total_bills) | money }}
+
+
+
Saved to Funds
+
${{ total_sinking | money }}
+
+
+
{{ 'Left Over' if net >= 0 else 'Over Budget' }}
+
+ ${{ net | abs | money }} +
+
+
+ + +{% if month_summary %} +
+
How's it going?
+ {% for line in month_summary %} +
{{ line | bold_md }}
+ {% endfor %} +
+{% endif %} + + +{% if total_income > 0 %} +{% set spent_pct = [(total_spending + total_bills) / total_income * 100, 100] | min | int %} +{% set sinking_pct = [total_sinking / total_income * 100, 100 - spent_pct] | min | int %} +
+
+ Where did the money go? + ${{ total_income | money }} income this month +
+
+
+
+
+
+ Spent {{ spent_pct }}% + Saved {{ sinking_pct }}% +
+
+{% endif %} + +{% if spending_by_cat %} +
+
+
+
{{ month_name }} Spending
+ {% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %} +
+ + {{ cat }} + ${{ "%.0f" | format(amt) }} +
+ {% endfor %} +
+
+{% endif %} + +
+ + +
+ + +
+
+

Income

+ manage → +
+ {% if income_by_stream %} + + + {% for s in income_streams %} + {% set amt = income_by_stream.get(s.name, 0) %} + + + + + + {% endfor %} + + + + + + + +
{{ s.name }}{{ s.owner }}${{ amt | money }}
Total · {{ year }} YTD ${{ "%.0f"|format(income_ytd_total) }}${{ total_income | money }}
+ {% else %} +

No income logged yet. Log income →

+ {% endif %} +
+ + +
+
+

Bills This Month

+ calendar → +
+ {% if monthly_bills %} + + + {% for b in monthly_bills %} + + + + + + {% endfor %} + + + + + + + +
{{ b.name }}day {{ b.due_day }}{% if b.amount %}${{ b.amount | money }}{% else %}{% endif %}
Total${{ total_bills | money }}
+ {% else %} +

No bills this month. Manage bills →

+ {% endif %} +
+ + + {% if sinking_contributions %} +
+
+

Saved to Funds

+ manage → +
+ + + {% for name, amt in sinking_contributions.items() %} + + + + + {% endfor %} + + + + + + + +
{{ name }}${{ amt | money }}
Total${{ total_sinking | money }}
+
+ {% endif %} + +
+ + +
+ + + {% if cash_accounts %} +
+
+

Account Balances

+ manage → +
+ + + {% for a in cash_accounts %} + + + + + + {% endfor %} + + + + + + + +
{{ a.name }}{{ a.type or '' }}${{ (a.balance or 0) | money }}
Total Cash${{ total_cash | money }}
+
+ {% else %} +
+
+

Account Balances

+
+

No accounts added yet. Add accounts →

+
+ {% endif %} + + +
+
+

Spending by Category

+ all transactions → +
+ {% if spending_by_cat %} + {% set max_spend = spending_by_cat.values() | max %} + + + + {% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %} + {% set budgeted = budgeted_by_cat.get(cat, 0) %} + {% set over = budgeted > 0 and amt > budgeted %} + + + + + + + {% endfor %} + + + + + + + + + +
CategoryBudgetedActual
{{ cat }}{% if budgeted %}${{ budgeted | money }}{% else %}{% endif %} + ${{ amt | money }} + {% if over %} over{% endif %} + +
+
+
+
Total{% if budgeted_by_cat %}${{ "%.2f"|format(budgeted_by_cat.values()|sum) }}{% endif %}${{ total_spending | money }}
+ {% else %} +

No categorized transactions yet. Review transactions →

+ {% endif %} +
+ + + {% if recent %} +
+
+

Recent Transactions

+ view all → +
+ + + {% for t in recent %} + + + + + + + {% endfor %} + +
{{ t.date }}{{ t.description }}${{ t.amount | money }}{{ t.category }}
+
+ {% endif %} + +
+
+ + +{% if budgeted_by_cat %} +
+
+

Budget vs Actual — {{ month_name }}

+
+
+ +
+
+{% endif %} + + +
+
+
🧾 {{ year }} Tax Estimate
+
+ ${{ tax_estimate.total | money }} estimated owed  ·  + ${{ tax_set_aside | money }} set aside  ·  + {% if tax_still_needed > 0 %} + ${{ tax_still_needed | money }} still needed + {% else %} + fully covered ✓ + {% endif %} +
+
+ View Details → +
+ + +
+
+
{{ month_name }} {{ year }} — Net
+
+ ${{ "%.2f"|format(total_income) }} income − ${{ "%.2f"|format(total_spending + total_bills) }} spent − ${{ "%.2f"|format(total_sinking) }} saved +
+
+
{% if net >= 0 %}+{% endif %}${{ net | money }}
+
+ + +{% endblock %} diff --git a/templates/month.html b/templates/month.html new file mode 100644 index 0000000..15d2102 --- /dev/null +++ b/templates/month.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
Total Income
+
${{ total_income | money }}
+
+
+
Total Spent
+
${{ (total_spending + total_bills) | money }}
+
+
+
Saved to Funds
+
${{ total_sinking | money }}
+
+
+
{{ 'Left Over' if net >= 0 else 'Over Budget' }}
+
+ ${{ net | abs | money }} +
+
+
+ + +{% if total_income > 0 %} +{% set spent_pct = [(total_spending + total_bills) / total_income * 100, 100] | min | int %} +{% set sinking_pct = [total_sinking / total_income * 100, 100 - spent_pct] | min | int %} +
+
+ Where did the money go? + ${{ total_income | money }} income +
+
+
+
+
+
+ Spent {{ spent_pct }}% + Saved {{ sinking_pct }}% + {% if net > 0 %} + Left over {{ [100 - spent_pct - sinking_pct, 0] | max }}% + {% endif %} +
+
+{% endif %} + +
+ + +
+ + +
+
+

Income

+ manage +
+ {% if income_by_stream %} + + + {% for s in income_streams %} + {% set amt = income_by_stream.get(s.name, 0) %} + {% if amt > 0 %} + + + + + + {% endif %} + {% endfor %} + + + + + + + +
{{ s.name }}{{ s.owner }}${{ amt | money }}
Total${{ total_income | money }}
+ {% else %} +

No income logged yet. Log income →

+ {% endif %} +
+ + +
+
+

Bills

+ manage +
+ {% if monthly_bills %} + + + {% for b in monthly_bills %} + + + + + + {% endfor %} + + + + + + + +
{{ b.name }}day {{ b.due_day }}{% if b.amount %}${{ b.amount | money }}{% else %}{% endif %}
Total${{ total_bills | money }}
+ {% else %} +

No bills this month.

+ {% endif %} +
+ + + {% if sinking_contributions %} +
+
+

Sinking Fund Contributions

+ manage +
+ + + {% for name, amt in sinking_contributions.items() %} + + + + + {% endfor %} + + + + + + + +
{{ name }}${{ amt | money }}
Total Saved${{ total_sinking | money }}
+
+ {% endif %} + +
+ + +
+ + +
+
+

Spending by Category

+ view transactions +
+ {% if spending_by_cat %} + {% set max_spend = spending_by_cat.values() | max %} + + + + {% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %} + {% set budgeted = budgeted_by_cat.get(cat, 0) %} + {% set over = budgeted > 0 and amt > budgeted %} + + + + + + + {% endfor %} + + + + + + + + + +
CategoryBudgetedActual
{{ cat }}{% if budgeted %}${{ budgeted | money }}{% else %}{% endif %} + ${{ amt | money }} + {% if over %} over{% endif %} + +
+
+
+
Total{% if budgeted_by_cat %}${{ budgeted_by_cat.values( | money|sum) }}{% endif %}${{ total_spending | money }}
+ {% else %} +

No categorized transactions yet. Review transactions →

+ {% endif %} +
+ + + {% if debt_payments %} +
+
+

Debt Payments

+ manage +
+ + + + {% for d in debt_payments %} + + + + + + + {% endfor %} + + + + + + + + +
DebtOwnerPaymentExtra
{{ d.name }}{{ d.owner }}${{ d.payment | money }}{% if d.extra > 0 %}+${{ d.extra | money }}{% else %}{% endif %}
Total Paid${{ total_debt_paid | money }}{% if total_extra_paid > 0 %}+${{ total_extra_paid | money }} extra{% endif %}
+
+ {% endif %} + +
+
+ + +
+
+
{{ month_name }} {{ year }} — Final Summary
+
+ ${{ "%.2f"|format(total_income) }} income + − ${{ "%.2f"|format(total_spending + total_bills) }} spent + − ${{ "%.2f"|format(total_sinking) }} saved +
+
+
+ {% if net >= 0 %}+{% endif %}${{ net | money }} +
+
+ +{% endblock %} diff --git a/templates/mortgage.html b/templates/mortgage.html new file mode 100644 index 0000000..5d0fa0d --- /dev/null +++ b/templates/mortgage.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% block content %} + + + +
+

Mortgage Details

+

Fill these in once you close on your home. Everything calculates automatically.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +{% if principal %} + +
+
+
Monthly Payment
+
${{ base_payment | money }}
+
+
+
Current Balance
+
${{ "{:,.0f}".format(current_bal) }}
+
+
+
Total Interest (standard)
+
${{ "{:,.0f}".format(total_interest_standard) }}
+
+
+
Payoff
+
{{ standard_months }} mo
+
+
+ + +
+

Payment Strategies

+
+ + +
+
Standard Monthly
+
Pay ${{ base_payment | money }}/month
+
Payoff{{ standard_months }} months ({{ (standard_months / 12) | round(1) }} yrs)
+
Total interest${{ "{:,.2f}".format(total_interest_standard) }}
+
Interest saved
+
+ + +
+
Biweekly Payments
+
Pay ${{ (base_payment / 2) | money }} every 2 weeks
+
Payoff{{ bi_months_total }} months ({{ bi_years }} yrs)
+
Total interest${{ "{:,.2f}".format(bi_interest) }}
+
Interest saved + ${{ "{:,.2f}".format(interest_saved_biweekly) }} + ({{ months_saved_biweekly }} months sooner) +
+
One extra payment per year
+
+ + + {% if extra_monthly > 0 %} +
+
Extra ${{ "%.0f" | format(extra_monthly) }}/Month
+
Pay ${{ (base_payment + extra_monthly) | money }}/month total
+
Payoff{{ extra_months }} months ({{ (extra_months / 12) | round(1) }} yrs)
+
Total interest${{ "{:,.2f}".format(total_interest_extra) }}
+
Interest saved + ${{ "{:,.2f}".format(interest_saved_extra) }} + ({{ months_saved_extra }} months sooner) +
+
+ {% else %} +
+
Extra Monthly Payment
+
Enter an extra monthly amount above to see the impact
+
+ {% endif %} + +
+
+ + +
+
+ + View Full Amortization Schedule + ({{ schedule | length }} payments) + +
+ + + + + + + + + + + + + {% for row in schedule %} + + + + + + + + + {% endfor %} + +
#DatePaymentPrincipalInterestBalance
{{ row.month }}{{ row.date }}${{ row.payment | money }}${{ row.principal | money }}${{ row.interest | money }}${{ "{:,.2f}".format(row.balance) }}
+
+
+
+ +{% else %} +
+

Enter your mortgage details above once you close on your home and everything will calculate automatically.

+
+{% endif %} +{% endblock %} diff --git a/templates/net_worth.html b/templates/net_worth.html new file mode 100644 index 0000000..f1ead07 --- /dev/null +++ b/templates/net_worth.html @@ -0,0 +1,225 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
Current Net Worth
+
${{ net_worth | money }}
+
+ Assets: ${{ total_assets | money }} + + Debts: ${{ total_debts | money }} +
+
+ +
+ + +
+

Assets

+ + +
+
+

Cash & Bank Accounts

+ ${{ total_cash | money }} +
+ + + + {% for acc in accounts %} + + + + + + + {% endfor %} + + + + + + + + +
AccountOwnerBalance
{{ acc.name }}{{ acc.owner }} + + +
+ +
+
Total Cash${{ total_cash | money }}
+ + +
+ + Add Account +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+

Sinking Funds

+ manage + ${{ total_sinking | money }} +
+ + + {% for f in sinking_funds %} + + + + + {% endfor %} + + + + + + + +
{{ f.name }}${{ (f.balance or 0) | money }}
Total${{ total_sinking | money }}
+
+ + + {% if personal_cars %} +
+
+

Vehicles

+ manage + ${{ total_vehicles | money }} +
+ + + {% for car in personal_cars %} + + + + + + {% endfor %} + + + + + + + +
{{ car.display_name }}{{ car.owner }} + {% if car.estimated_value %} + ${{ car.estimated_value | float | money }} + {% else %} + no value set + {% endif %} +
Total${{ total_vehicles | money }}
+
+ {% endif %} + + + {% if other_assets %} +
+
+

Other Assets

+ manage + ${{ total_other_assets | money }} +
+ + + {% for a in other_assets %} + + + + + + {% endfor %} + + + + + + + +
{{ a.name }}{{ a.category }}${{ a.value | float | money }}
Total${{ total_other_assets | money }}
+
+ {% endif %} + +
+ + +
+

Liabilities (Debts)

+ + {% for cat in debt_categories %} + {% set cat_debts = debts_by_cat[cat] %} + {% if cat_debts %} +
+
+

{{ cat }}

+ ${{ debt_cat_totals[cat] | money }} +
+ + + {% for d in cat_debts %} + + + + + + {% endfor %} + + + + + + + +
{{ d.name }}{{ d.owner }}${{ d.current_balance | float | money }}
Subtotal${{ debt_cat_totals[cat] | money }}
+
+ {% endif %} + {% endfor %} + +
+ Total Debt + ${{ total_debts | money }} +
+
+ +
+ + +{% endblock %} diff --git a/templates/review.html b/templates/review.html new file mode 100644 index 0000000..79b1693 --- /dev/null +++ b/templates/review.html @@ -0,0 +1,396 @@ +{% extends "base.html" %} +{% block content %} + + +{% if remaining %} +
+ Need to bulk-clear old transactions? +
+
+ + + +
+
+
+{% endif %} + +{% if not transaction %} +
+
+

All caught up!

+

Every transaction has been reviewed.

+ View All Transactions +
+{% else %} + +
+
+ {{ transaction.date }} + +
+
{{ transaction.description }}
+
${{ transaction.amount | money }}
+ + {% if bnpl_lender and bnpl_matches %} +
+
💳 Looks like a {{ bnpl_lender | title }} payment — which loan is this?
+
+ {% for loan in bnpl_matches %} +
+ + + + +
+ {% endfor %} +
+
Selecting a loan will mark one payment as made and categorize this as Debt. Or ignore this and categorize manually below.
+
+ {% elif bnpl_lender %} +
+
💳 Looks like a {{ bnpl_lender | title }} payment — no active loans found
+
Want to set up this loan now so future payments get tracked automatically?
+ + +
+ {% endif %} + + {% if suggestion %} +
+ Suggested: {{ suggestion.category }}{% if suggestion.subcategory %} › {{ suggestion.subcategory }}{% endif %} +
+ + + + +
+
+ {% endif %} + +
+ + +
+ +
+ {% for cat, subcats in categories.items() %} + + {% endfor %} +
+
+ +
+ + +
+ + + + + + + +
+ + +
+ +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+ + + +
+
+ + + +
+
+ + +
+
+ + + +
+ + +{% endif %} +{% endblock %} diff --git a/templates/sinking.html b/templates/sinking.html new file mode 100644 index 0000000..54b6afb --- /dev/null +++ b/templates/sinking.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
Total Across All Funds
+
${{ total | money }}
+
+
Match this number to your savings account balance
+
+ + +
+ {% for f in funds %} + {% set color = fund_colors.get(f.name, '#C5BBA0') %} +
+
+
{{ f.name }}
+ + {{ 'Goal' if f.type == 'goal' else 'Ongoing' }} + +
+ + {% if f.notes %} +
{{ f.notes }}
+ {% endif %} + +
${{ (f.balance or 0) | money }}
+ + {% if f.type == 'goal' and f.goal %} + {% set pct = f.pct or 0 %} +
+
+
+
+
+ ${{ (f.balance or 0) | money }} of ${{ f.goal | money }} ({{ pct }}%) +
+
+ {% if f.monthly_needed %} +
+ Save ${{ f.monthly_needed | money }}/mo + to reach goal + {% if f.months_left %} in {{ f.months_left }} months{% endif %} +
+ {% endif %} + {% else %} + {% if f.monthly %} +
+ Monthly contribution: ${{ f.monthly | money }} +
+ {% endif %} + {% endif %} + + +
+
+ + + +
+
+ + + +
+
+ + +
+ Edit settings +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {% endfor %} + + +
+
+ New Fund
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +{% endblock %} diff --git a/templates/strategy.html b/templates/strategy.html new file mode 100644 index 0000000..13a0cee --- /dev/null +++ b/templates/strategy.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% block content %} + + +
+

How much can you put toward debt each month?

+

Your current total minimums are ${{ total_minimums | money }}. Enter your total monthly debt payment below — anything above the minimums gets applied strategically.

+
+
+ + +
+ +
+
+ +{% if av_months %} + +
+
+
Avalanche Method
+
Highest interest rate first
+
+ Debt-free in + {{ av_months }} months +
+
+ Total interest paid + ${{ av_interest | money }} +
+
Saves the most money
+
+ +
VS
+ +
+
Snowball Method
+
Smallest balance first
+
+ Debt-free in + {{ sb_months }} months +
+
+ Total interest paid + ${{ sb_interest | money }} +
+
Faster wins, more motivation
+
+
+ +{% if interest_saved > 0 or months_diff != 0 %} +
+ {% if interest_saved > 0 %} + The Avalanche method saves you ${{ interest_saved | money }} in interest + {% endif %} + {% if months_diff > 0 %} + The Snowball method takes {{ months_diff }} month{{ 's' if months_diff != 1 }} longer + {% elif months_diff < 0 %} + The Avalanche method takes {{ months_diff|abs }} month{{ 's' if months_diff|abs != 1 }} longer + {% endif %} +
+{% endif %} + + +
+
+

Avalanche Payoff Order

+ + + + + + {% for d in avalanche | sort(attribute='paid_off_month') %} + + + + + + + + {% endfor %} + +
#DebtOwnerPaid OffInterest Paid
{{ loop.index }}{{ d.name }}{{ d.owner }}{{ d.paid_off_month or '—' }}${{ d.total_interest | money }}
+
+ +
+

Snowball Payoff Order

+ + + + + + {% for d in snowball | sort(attribute='paid_off_month') %} + + + + + + + + {% endfor %} + +
#DebtOwnerPaid OffInterest Paid
{{ loop.index }}{{ d.name }}{{ d.owner }}{{ d.paid_off_month or '—' }}${{ d.total_interest | money }}
+
+
+ +{% else %} +
+

Add your debts and monthly payment amount above to see your payoff strategy.

+
+{% endif %} +{% endblock %} diff --git a/templates/summary.html b/templates/summary.html new file mode 100644 index 0000000..6e4daf8 --- /dev/null +++ b/templates/summary.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block content %} + + + +{% if by_category %} + + +
+ +
+ +
+ {% for cat, data in by_category | dictsort %} +
+
{{ cat }}
+
${{ data.total | money }}
+ {% if data.subcategories %} +
    + {% for sub, amt in data.subcategories | dictsort %} +
  • + {{ sub }} + ${{ amt | money }} +
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+{% else %} +
+

No categorized transactions yet. Import a statement to get started.

+
+{% endif %} + + +{% endblock %} diff --git a/templates/taxes.html b/templates/taxes.html new file mode 100644 index 0000000..e8ac4be --- /dev/null +++ b/templates/taxes.html @@ -0,0 +1,187 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
YTD Income
+
${{ ytd_total | money }}
+
+
+
Estimated Tax Owed
+
${{ estimate.total | money }}
+
~{{ estimate.effective_rate }}% effective rate
+
+
+
Set Aside So Far
+
${{ total_set_aside | money }}
+
+
+ {% if over_saved > 0 %} +
Over-Saved
+
${{ over_saved | money }}
+
nice work!
+ {% else %} +
Still Need to Set Aside
+
${{ still_needed | money }}
+ {% endif %} +
+
+ + +{% if estimate.total > 0 %} +{% set pct = [(total_set_aside / estimate.total * 100), 100] | min | int %} +
+
+ Tax savings progress + {{ pct }}% saved +
+
+
+
+
+{% endif %} + + +
+ + +
+
+

How We Got There

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Self-employment income (Bonna + Car Flipping): ${{ ytd_se | money }}  ·  + W-2 income (Tony / Auto King): ${{ ytd_w2 | money }} +
Self-Employment Tax (SS + Medicare on SE income only)${{ estimate.se_tax | money }}
Federal Income Tax (~{{ rates.federal }}% after $30k MFJ deduction)${{ estimate.federal | money }}
Wisconsin State Tax (~{{ rates.wi_state }}% after $30k MFJ deduction)${{ estimate.wi_state | money }}
Total Estimated${{ estimate.total | money }}
+
+ Adjust tax rates +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+

Quarterly Estimates

+
+ + + + {% for q in quarters %} + + + + + + + {% endfor %} + +
QuarterIncomeEst. TaxDue
{{ q.label }}${{ q.income | money }}${{ q.estimate.total | money }}{{ q.due_date }}
+

These are federal estimated payment deadlines. WI follows the same schedule.

+
+ +
+ + +
+
+

Tax Savings Log

+
+
+ + Log Money Set Aside +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + {% if set_aside_log %} + + + + {% for e in set_aside_log | sort(attribute='date', reverse=True) %} + + + + + + + {% endfor %} + + + + + + + + +
DateAmountNotes
{{ e.date }}${{ e.amount | money }}{{ e.notes }} +
+ +
+
Total Set Aside${{ total_set_aside | money }}
+ {% else %} +

Nothing logged yet. Every time you move money to your tax savings account, record it here.

+ {% endif %} +
+ +
+ Heads up: These are estimates based on flat rates and may not perfectly match your actual tax bill. We recommend setting aside the estimated amount and working with a tax professional when it's time to file. The SE tax calculation uses the IRS method (92.35% of net income) and deducts half of SE tax before calculating income tax, which is how the IRS does it. +
+{% endblock %} diff --git a/templates/transactions.html b/templates/transactions.html new file mode 100644 index 0000000..21c0a53 --- /dev/null +++ b/templates/transactions.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} +{% block content %} + + +{% with messages = get_flashed_messages(with_categories=true) %} +{% for category, message in messages %} +
+ {{ message }} + {% if category == 'success' %} + Review Now + {% endif %} +
+{% endfor %} +{% endwith %} + + +
+ ⬆️ Import Statement +
+

Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.

+
+
+ + +
+
+ +
+ +

Click to choose a file, or drag and drop here

+

PDF or CSV

+
+
+ +
+
+ How to export your statements ▾ +
    +
  • WestConsin Credit Union: Log in → select your account → Download/Export → choose CSV → pick date range
  • +
  • Most other banks: Log in → Account Details or Transaction History → Export / Download → choose CSV
  • +
  • Affirm: affirm.com → Account → Payment History → Export
  • +
  • Klarna: klarna.com → Purchases → Export CSV
  • +
  • Afterpay: afterpay.com → Orders → Export
  • +
+

If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.

+
+
+
+ +{% if spending_by_cat %} +
+ 📊 {{ week_label }} Spending +
+ + + + + + {% for cat, amt in spending_by_cat.items() %} + {% set budgeted = budgeted_by_cat.get(cat, 0) %} + {% set remaining = budgeted - amt %} + {% set over = budgeted > 0 and amt > budgeted %} + {% set pct = [(amt / budgeted * 100)|int, 100]|min if budgeted > 0 else 100 %} + + + + + + + + {% endfor %} + + + + + + + + + +
CategorySpentBudgetedRemaining
{{ cat }}${{ amt | money }}{% if budgeted %}${{ budgeted | money }}{% else %}{% endif %} + {% if budgeted %} + {% if over %} + -${{ (amt - budgeted) | money }} over + {% else %} + ${{ remaining | money }} + {% endif %} + {% else %} + + {% endif %} + +
+
+
+
Total${{ spending_by_cat.values() | sum | money }}{% if budgeted_by_cat %}${{ budgeted_by_cat.values() | sum | money }}{% endif %}
+
+
+{% endif %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {% if filter_from or filter_to or filter_cat or filter_search %} + Clear + {% endif %} +
+ +{% if transactions %} + + + + + + + + + + + + + + {% for t in transactions %} + + + + + + + + + + {% endfor %} + +
DateDescriptionAccountAmountCategoryNotes
{{ t.date }}{{ t.description }}{{ t.account }}${{ t.amount | money }} + {% if t.category %} + + {{ t.category }}{% if t.subcategory %} › {{ t.subcategory }}{% endif %} + + {% endif %} + {{ t.notes }}Edit
+{% else %} +
+

No transactions yet. Import a statement to get started.

+
+{% endif %} +{% endblock %} diff --git a/templates/weekly.html b/templates/weekly.html new file mode 100644 index 0000000..5c7ad86 --- /dev/null +++ b/templates/weekly.html @@ -0,0 +1,484 @@ +{% extends "base.html" %} +{% block content %} + + +
+ {% for w in weeks %} + + {% endfor %} +
+ +{% for w in weeks %} +
+ + + {% if w.summary %} +
+ How's this week looking? + {% for line in w.summary %} + {{ line | bold_md }} + {% endfor %} +
+ {% endif %} + + +
+ + + Enter what's in checking when the week begins +
+ + +
+
+

Income

+
+
+ + + + {% for src in w.income_sources %} + {% set inc = w.income.get(src, {}) %} + + + + + + {% endfor %} + + + + + + + + +
SourceBudgetedActual
+ {{ src }} + {% if src == 'Auto King' %} + Fridays + {% endif %} + +
+ + {% if inc.get('actual') %} + ✓ logged + {% endif %} +
+
Total${{ (w.income.values() | map(attribute='budgeted') | select | map('float') | sum if w.income else 0) | money }}${{ w.income_total | money }}
+
+
+ + +
+
+

Bills Due This Week

+ manage bills +
+
+ {% if w.bills %} + + + + {% for b in w.bills %} + + + + + + + {% endfor %} + + + + + + + + +
BillDueAmountCategory
{{ b.name }}{{ b.due_date.strftime('%b %-d') if b.due_date else '' }}{% if b.amount %}${{ b.amount | money }}{% else %}{% endif %} + {% if b.category %} + {{ b.category }} + {% endif %} +
Bills Total${{ w.bills_total | money }}
+ {% else %} +

No bills due this week.

+ {% endif %} +
+
+ + +
+
+

Budget Categories

+
+
+ {% set skip = ['Income', 'Debt', 'Transfer'] %} + {% set total_budgeted = namespace(v=0) %} + {% set total_actual = namespace(v=0) %} + + + + {% for cat in categories if cat not in skip %} + {% set cb = w.cat_budget.get(cat, {}) %} + {% set actual = w.cat_actuals.get(cat, 0) %} + {% set budgeted = cb.get('budgeted', '') | float %} + {% set left = budgeted - actual %} + {% set over = budgeted > 0 and actual > budgeted %} + {% set total_budgeted.v = total_budgeted.v + budgeted %} + {% set total_actual.v = total_actual.v + actual %} + + + + + + + {% endfor %} + + + {% set total_left = total_budgeted.v - total_actual.v %} + + + + + + + +
CategoryBudgetedSpentLeft to Spend
+ {{ cat }} + + ${{ actual | money }} + + {% if budgeted %} + {% if over %} + -${{ (actual - budgeted) | money }} + {% else %} + ${{ left | money }} + {% endif %} + {% else %} + + {% endif %} +
Totals${{ total_budgeted.v | money }}${{ total_actual.v | money }} + {% if total_budgeted.v > 0 %} + {% if total_left < 0 %} + -${{ total_left | abs | money }} over + {% else %} + ${{ total_left | money }} left + {% endif %} + {% endif %} +
+
+
+ + +
+
+

Sinking Funds

+ manage funds +
+ +
+ + +
+
+

Extra Debt Payments

+ manage debts +
+ +
+ + +
+
+

Transactions This Week{% if w.transactions %} ({{ w.transactions | length }}){% endif %}

+
+ +
+ + +
+
+
Week {{ w.num }} — What's Left
+
+ ${{ w.starting_balance | money }} starting + + ${{ w.income_total | money }} income + − ${{ w.spending_total | money }} spending + − ${{ w.sinking_total | money }} savings + − ${{ w.extra_debt_total | money }} extra debt +
+
+ ${{ w.leftover | money }} +
+ +
+{% endfor %} + + +{% endblock %}