commit a3b91f6d6b9b33e1d348a699a6c5da560af3a594 Author: Bonna Date: Sat Jun 6 19:12:43 2026 -0500 Initial commit: moon-household-budget diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..27c275c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,50 @@ +{ + "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 *)", + "Bash(git commit *)", + "Bash(git branch *)", + "Bash(git config *)", + "Bash(git remote *)", + "Bash(git push *)", + "Bash(pip freeze *)", + "Bash(pip3 freeze *)", + "Bash(/usr/local/bin/docker --version)", + "Bash(/Applications/Docker.app/Contents/Resources/bin/docker --version)", + "Bash(ssh root@192.168.0.5 'mkdir -p /mnt/user/appdata/moon-household-budget')", + "Bash(ssh root@192.168.0.5 'docker ps --format \"table {{.Names}}\\\\t{{.Status}}\\\\t{{.Ports}}\" | grep moon')", + "Bash(curl -fsS -o /dev/null -w \"live app: %{http_code}\\\\n\" http://192.168.0.5:8052/)", + "Bash(rsync -av --exclude='.git/' --exclude='data/' --exclude='moon-household-budget-handoff/' /Users/bonnamoon/Projects/fullmoonbakehouse/ root@192.168.0.5:/mnt/user/appdata/moon-household-budget/)", + "Bash(ssh root@192.168.0.5 'cd /mnt/user/appdata/moon-household-budget && docker compose up -d --build 2>&1 | tail -10')", + "Bash(ssh root@192.168.0.5 'cd /mnt/user/appdata/moon-household-budget && docker compose up -d --build 2>&1 | tail -6')", + "Bash(ssh root@192.168.0.5 'mkdir -p /mnt/user/appdata/moon-household-budget/data')", + "Bash(rsync -av /Users/bonnamoon/Projects/fullmoonbakehouse/data/ root@192.168.0.5:/mnt/user/appdata/moon-household-budget/data/)", + "Bash(git -C /Users/bonnamoon/Projects/fullmoonbakehouse status)", + "Bash(git -C /Users/bonnamoon/Projects/fullmoonbakehouse diff)", + "Bash(git -C /Users/bonnamoon/Projects/fullmoonbakehouse add .claude/settings.local.json)", + "Bash(git -C /Users/bonnamoon/Projects/fullmoonbakehouse commit -m ' *)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1bf53bd --- /dev/null +++ b/.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/.gitignore b/.gitignore new file mode 100644 index 0000000..1f34b5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.env +*.env +!.env.example +data/ +*.sqlite +*.sqlite3 +*.db +*.db-journal +__pycache__/ +*.pyc +.venv/ +venv/ +env/ +.DS_Store +.idea/ +.vscode/ +bonnasclaude.md + +# handoff/scaffolding folder — not part of the app +moon-household-budget-handoff/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..74dc441 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to Moon Household Budget. Newest first. +Generated from changelog.json by release.py — do not edit by hand. + +## 0.2.0 — 2026-06-06 — Automatic version + changelog on every update + +- Every app update now automatically bumps the version and adds a 'What's New' entry, so you always see what changed. +- Added a one-command release tool and wrote the process into the project so any future change stays documented. + +## 0.1.0 — 2026-06-06 — First tracked release + +- Crash-safe data storage — saves are now atomic and a corrupted file can no longer take the app down. +- Transactions moved to a SQLite database: faster, and safe when edited from two tabs at once. Existing transactions were migrated automatically. +- New Settings page (⚙️) to store your Gmail login and reminder preferences securely — no passwords in the code anymore. +- Suggestions & feedback (💡) is now reachable from every page; what you send becomes a tracked issue. +- Bill email reminders now use the Gmail login from Settings. +- Automatic daily backups of all your data (local for now; Backblaze cloud once a bucket is set up). +- Fixed the Monthly view crashing on the budget total. +- Added this version number and changelog. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..596ce8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# Moon Household Budget + +Personal household-budgeting web app for Tony & Bonna's household. Self-hosted +on the home NAS — single household, not a product. No billing, no multi-tenancy. + +## Stack +- **Flask + gunicorn** (1 worker + threads — state lives in files on the volume, + so multiple processes would race). Jinja templates all extend `base.html`. +- **Storage:** small data = JSON files via `storage.py` (atomic writes, + crash-safe loads). **Transactions = SQLite** (`txns_db.py`, `transactions.db`) + with the same list-of-dicts API as the old JSON. +- **Settings/secrets:** `/settings` page → `settings.py` → `settings.json` on the + volume (never in git). `reminders.py` reads Gmail creds from there. +- **Suggestions (💡):** `suggestions/` blueprint files feedback as Gitea issues + (needs `GITEA_URL/GITEA_TOKEN/GITEA_REPO` in `.env`). +- **Version chip + changelog:** `version.py` + `/changelog`, sourced from + `changelog.json`. + +## ⚠️ Release process — REQUIRED on every deploy +Every deploy MUST cut a release so users see what changed. Before deploying: + +```sh +python release.py "" "" [--title "..."] +``` + +- Bump the **patch** (0.1.0 → 0.1.1) for fixes, the **minor** (0.1.0 → 0.2.0) + for new features. +- Write changes in plain language Bonna will understand (not code-speak). +- This updates `changelog.json` (drives `__version__`, the footer chip, and + `/changelog`) and regenerates `CHANGELOG.md`. Commit both with your change. +- **Do not deploy without a new changelog entry.** + +## Deploy (Docker on Unraid NAS — http://192.168.0.5:8052) +```sh +# from the repo root, after committing (incl. the release entry): +rsync -a --exclude='.git/' --exclude='data/' --exclude='.env' --exclude='__pycache__/' \ + ./ root@192.168.0.5:/mnt/user/appdata/moon-household-budget/ +ssh root@192.168.0.5 'cd /mnt/user/appdata/moon-household-budget && docker compose up -d --build' +``` +- **Never** rsync `data/` or `.env` — that's live data and secrets on the volume. +- Templates are baked into the image, so template changes need `--build`. +- Backups: the `backup` sidecar tars `/data` to `/mnt/user/Backup/moon-household-budget/` + daily; pushes to Backblaze B2 only once a real bucket/key is in `.env`. + +## Conventions +- Secrets never go in source — use `.env` (container) or `settings.json` (volume). +- Keep other small JSON files as JSON; only `transactions` warranted SQLite. +- Repo lives on the home Gitea: `bonna61/Moon-Household-Budget`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..477399a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# 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"] + +# Single worker + threads: the app stores state in JSON/SQLite files on the +# volume, so multiple processes would race. Threads handle light concurrency. +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "4", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..c0af29c --- /dev/null +++ b/app.py @@ -0,0 +1,2900 @@ +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 storage import load_json, save_json +from settings import load_settings, save_settings, SECRET_KEYS +import txns_db +import auth +from version import __version__ as APP_VERSION, CHANGELOG +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 = os.environ.get("SECRET_KEY", "fullmoonbakehouse") + +from suggestions.routes_flask import suggestions_bp +app.register_blueprint(suggestions_bp) + +# Passkey (WebAuthn) auth — blueprint + login gate. No-op until AUTH_ENABLED=1. +from auth_routes import init_auth +init_auth(app) + +from flask import send_from_directory + +@app.route("/uploads/") +def _uploads(p): + return send_from_directory("/data/uploads", p) + +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.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")), "taxes.json") + +def load_taxes(): + return load_json(TAX_FILE, {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []}) + +def save_taxes(data): + save_json(TAX_FILE, data) + +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(): + # Fast COUNT query instead of parsing the whole transaction store per request. + return dict(unreviewed_count=txns_db.count_unreviewed()) + + +@app.context_processor +def inject_version(): + return dict(app_version=APP_VERSION) + +DATA_DIR = os.environ.get("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") + +# Transactions live in SQLite (self-contained, on the volume). On first run this +# imports any existing transactions.json; it's a no-op once the DB has rows. +txns_db.init_db() +_migrated = txns_db.migrate_from_json(TRANSACTIONS_FILE) +if _migrated: + print(f"[startup] migrated {_migrated} transactions from JSON -> SQLite") + + +def load_nw_accounts(): + return load_json(NET_WORTH_FILE, []) + + +def save_nw_accounts(accounts): + save_json(NET_WORTH_FILE, accounts) + + +def get_transactions(): + return txns_db.get_transactions() + + +def save_transactions(transactions): + txns_db.save_transactions(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)) + + +@app.route("/settings", methods=["GET", "POST"]) +def settings_view(): + current = load_settings() + if request.method == "POST": + new = dict(current) + new["gmail_address"] = request.form.get("gmail_address", "").strip() + new["reminder_recipient"] = request.form.get("reminder_recipient", "").strip() + new["reminders_enabled"] = request.form.get("reminders_enabled") == "on" + # Secret fields: only overwrite when a new value is supplied; a blank + # field keeps the existing stored secret. + pw = request.form.get("gmail_app_password", "").strip() + if pw: + new["gmail_app_password"] = pw + save_settings(new) + return redirect(url_for("settings_view", saved=1)) + # Never send secret values to the template — only whether each one is set. + safe = {k: ("" if k in SECRET_KEYS else v) for k, v in current.items()} + secrets_status = {k: bool(current.get(k)) for k in SECRET_KEYS} + return render_template("settings.html", settings=safe, + secrets_status=secrets_status, + saved=request.args.get("saved"), + passkeys=auth.list_credentials(), + auth_enabled=auth.auth_enabled()) + + +@app.route("/changelog") +def changelog_view(): + return render_template("changelog.html", changelog=CHANGELOG, app_version=APP_VERSION) + + +if __name__ == "__main__": + app.run(debug=os.environ.get("FLASK_DEBUG") == "1", port=5000) diff --git a/assets.py b/assets.py new file mode 100644 index 0000000..ef1ac62 --- /dev/null +++ b/assets.py @@ -0,0 +1,87 @@ +import copy +import json +import os +from storage import load_json, save_json + +DATA_DIR = os.environ.get("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(): + data = load_json(ASSETS_FILE, None) + if data is None: + data = {"personal": copy.deepcopy(DEFAULT_CARS), "flips": [], "other_assets": []} + save_assets(data) + return data + # migrate old format if needed + if "other_assets" not in data: + data["other_assets"] = [] + return data + + +def save_assets(data): + save_json(ASSETS_FILE, data) + + +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/auth.py b/auth.py new file mode 100644 index 0000000..68f03e1 --- /dev/null +++ b/auth.py @@ -0,0 +1,94 @@ +"""Passkey (WebAuthn) credential storage + config. + +One "household" identity, many enrolled devices (each device = one credential). +Credentials live in passkeys.json on the volume (public keys only — there is no +shared password to leak). RP config + the AUTH_ENABLED gate come from env so the +hostname can change (mkcert host, Tailscale ts.net name, etc.) without code edits. +""" +import os +import secrets + +from storage import DATA_DIR, load_json, save_json + +PASSKEYS_FILE = os.path.join(DATA_DIR, "passkeys.json") + + +# ---- config (env-driven) ---- +def rp_id(): + return os.environ.get("WEBAUTHN_RP_ID", "budget.nastynas.xyz") + + +def rp_name(): + return os.environ.get("WEBAUTHN_RP_NAME", "Moon Household Budget") + + +def origin(): + return os.environ.get("WEBAUTHN_ORIGIN", f"https://{rp_id()}") + + +def auth_enabled(): + return os.environ.get("AUTH_ENABLED", "0").lower() in ("1", "true", "yes", "on") + + +def enrollment_code(): + return os.environ.get("PASSKEY_ENROLLMENT_CODE", "") + + +# ---- credential store ---- +def _data(): + d = load_json(PASSKEYS_FILE, {}) or {} + changed = False + if "credentials" not in d: + d["credentials"] = [] + changed = True + if not d.get("user_handle"): + d["user_handle"] = secrets.token_urlsafe(16) + changed = True + if changed: + save_json(PASSKEYS_FILE, d) + return d + + +def user_handle_bytes(): + return _data()["user_handle"].encode() + + +def list_credentials(): + return _data()["credentials"] + + +def has_credentials(): + return len(_data()["credentials"]) > 0 + + +def get_credential(cred_id_b64): + for c in _data()["credentials"]: + if c["id"] == cred_id_b64: + return c + return None + + +def add_credential(cred_id_b64, public_key_b64, sign_count, label, transports=None): + d = _data() + d["credentials"].append({ + "id": cred_id_b64, + "public_key": public_key_b64, + "sign_count": sign_count, + "label": label or "Device", + "transports": transports or [], + }) + save_json(PASSKEYS_FILE, d) + + +def update_sign_count(cred_id_b64, new_count): + d = _data() + for c in d["credentials"]: + if c["id"] == cred_id_b64: + c["sign_count"] = new_count + save_json(PASSKEYS_FILE, d) + + +def remove_credential(cred_id_b64): + d = _data() + d["credentials"] = [c for c in d["credentials"] if c["id"] != cred_id_b64] + save_json(PASSKEYS_FILE, d) diff --git a/auth_routes.py b/auth_routes.py new file mode 100644 index 0000000..649e27b --- /dev/null +++ b/auth_routes.py @@ -0,0 +1,198 @@ +"""WebAuthn passkey routes + the login gate. + +Pages: /login (passkey sign-in), /enroll (register a device), /logout. +JSON ceremony endpoints under /auth/*. init_auth(app) wires the blueprint and a +before_request gate that protects everything when AUTH_ENABLED is on. +""" +import json +from datetime import timedelta + +from flask import (Blueprint, abort, jsonify, redirect, render_template, + request, session, url_for) +from webauthn import (generate_authentication_options, + generate_registration_options, options_to_json, + verify_authentication_response, + verify_registration_response) +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.structs import (AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement) + +import auth + +auth_bp = Blueprint("auth", __name__) + +# Endpoints reachable WITHOUT a session (the login/enroll flow itself). +OPEN_ENDPOINTS = { + "auth.login", "auth.enroll", "auth.logout", + "auth.register_begin", "auth.register_complete", + "auth.login_begin", "auth.login_complete", + "static", +} + + +def _json_body(): + return request.get_json(silent=True) or {} + + +def _enroll_authorized(): + """May the caller register a new device right now?""" + if session.get("authed"): + return True + code = auth.enrollment_code() + if code: + supplied = (_json_body().get("code") + or request.args.get("code") + or request.headers.get("X-Enroll-Code")) + return supplied == code + # No code configured: allow only to bootstrap the very first device. + return not auth.has_credentials() + + +# ---- pages ---- +@auth_bp.route("/login") +def login(): + if session.get("authed"): + return redirect(url_for("index")) + return render_template("login.html", rp_id=auth.rp_id(), + has_credentials=auth.has_credentials()) + + +@auth_bp.route("/enroll") +def enroll(): + return render_template("enroll.html", rp_id=auth.rp_id(), + needs_code=bool(auth.enrollment_code()) and not session.get("authed"), + logged_in=bool(session.get("authed"))) + + +@auth_bp.route("/logout", methods=["GET", "POST"]) +def logout(): + session.clear() + return redirect(url_for("auth.login")) + + +# ---- registration ceremony ---- +@auth_bp.route("/auth/register/begin", methods=["POST"]) +def register_begin(): + if not _enroll_authorized(): + return jsonify({"error": "not_authorized"}), 403 + creds = auth.list_credentials() + opts = generate_registration_options( + rp_id=auth.rp_id(), + rp_name=auth.rp_name(), + user_name="household", + user_id=auth.user_handle_bytes(), + user_display_name="Moon Household", + exclude_credentials=[ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["id"])) for c in creds + ], + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + ) + session["reg_challenge"] = bytes_to_base64url(opts.challenge) + session["reg_label"] = (_json_body().get("label") or "Device")[:60] + session["enroll_ok"] = True + return options_to_json(opts), 200, {"Content-Type": "application/json"} + + +@auth_bp.route("/auth/register/complete", methods=["POST"]) +def register_complete(): + if not session.pop("enroll_ok", False): + return jsonify({"error": "not_authorized"}), 403 + challenge = session.pop("reg_challenge", None) + if not challenge: + return jsonify({"error": "no_challenge"}), 400 + try: + v = verify_registration_response( + credential=request.get_data(as_text=True), + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=auth.rp_id(), + expected_origin=auth.origin(), + require_user_verification=False, + ) + except Exception as e: + return jsonify({"error": f"verify_failed: {e}"}), 400 + auth.add_credential( + bytes_to_base64url(v.credential_id), + bytes_to_base64url(v.credential_public_key), + v.sign_count, + session.pop("reg_label", "Device"), + ) + session["authed"] = True + session.permanent = True + return jsonify({"ok": True}) + + +# ---- authentication ceremony ---- +@auth_bp.route("/auth/login/begin", methods=["POST"]) +def login_begin(): + creds = auth.list_credentials() + opts = generate_authentication_options( + rp_id=auth.rp_id(), + allow_credentials=[ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["id"])) for c in creds + ], + user_verification=UserVerificationRequirement.PREFERRED, + ) + session["auth_challenge"] = bytes_to_base64url(opts.challenge) + return options_to_json(opts), 200, {"Content-Type": "application/json"} + + +@auth_bp.route("/auth/login/complete", methods=["POST"]) +def login_complete(): + challenge = session.pop("auth_challenge", None) + if not challenge: + return jsonify({"error": "no_challenge"}), 400 + body = request.get_data(as_text=True) + cred = auth.get_credential(json.loads(body).get("id")) + if not cred: + return jsonify({"error": "unknown_credential"}), 400 + try: + v = verify_authentication_response( + credential=body, + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=auth.rp_id(), + expected_origin=auth.origin(), + credential_public_key=base64url_to_bytes(cred["public_key"]), + credential_current_sign_count=cred["sign_count"], + require_user_verification=False, + ) + except Exception as e: + return jsonify({"error": f"verify_failed: {e}"}), 400 + auth.update_sign_count(cred["id"], v.new_sign_count) + session["authed"] = True + session.permanent = True + return jsonify({"ok": True}) + + +@auth_bp.route("/auth/device/remove", methods=["POST"]) +def device_remove(): + if auth.auth_enabled() and not session.get("authed"): + abort(403) + cid = request.form.get("id") + if cid: + auth.remove_credential(cid) + return redirect(url_for("settings_view")) + + +def init_auth(app): + app.permanent_session_lifetime = timedelta(days=30) + app.register_blueprint(auth_bp) + + @app.before_request + def _gate(): + if not auth.auth_enabled(): + return + ep = request.endpoint or "" + if ep in OPEN_ENDPOINTS or ep.startswith("auth."): + return + if request.path.startswith(("/static", "/suggestions-static", "/auth/")): + return + if session.get("authed"): + return + if request.path.startswith("/api/") or request.is_json: + return jsonify({"error": "unauthorized"}), 401 + return redirect(url_for("auth.login", next=request.path)) diff --git a/backup/Dockerfile b/backup/Dockerfile new file mode 100644 index 0000000..df6850e --- /dev/null +++ b/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/backup/backup.sh b/backup/backup.sh new file mode 100644 index 0000000..9e1e1bf --- /dev/null +++ b/backup/backup.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Back up the app's data under /data (the budget JSON files, imported PDFs/CSVs, +# and uploaded images). This app stores everything as plain JSON files — there +# is no SQLite database. +# +# Always writes a timestamped tar.gz to the local NAS path (/backups, mounted +# from the host). ALSO pushes to Backblaze B2 — but only when a real bucket and +# credentials are configured, so it's harmless before B2 is set up. +# +# rclone's B2 remote ("b2:") is configured from the RCLONE_CONFIG_B2_* env vars. +# Runs once; the container loops it on BACKUP_INTERVAL. +set -eu + +STAMP=$(date +%Y%m%d-%H%M%S) +KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" +LOCAL_DIR="/backups" + +echo "[backup ${STAMP}] starting" + +# 1. Local timestamped snapshot of everything under /data --------------------- +mkdir -p "${LOCAL_DIR}" +ARCHIVE="${LOCAL_DIR}/moon-data-${STAMP}.tar.gz" +tar -czf "${ARCHIVE}" -C /data . +echo "[backup ${STAMP}] local snapshot: ${ARCHIVE} ($(du -h "${ARCHIVE}" | cut -f1))" + +# prune old local snapshots +find "${LOCAL_DIR}" -name 'moon-data-*.tar.gz' -type f -mtime +"${KEEP_DAYS}" -delete 2>/dev/null || true + +# 2. Optional Backblaze B2 push (only when a real bucket + key are set) -------- +if [ -n "${B2_BUCKET:-}" ] && [ "${B2_BUCKET#__}" = "${B2_BUCKET}" ] \ + && [ -n "${RCLONE_CONFIG_B2_ACCOUNT:-}" ] && [ "${RCLONE_CONFIG_B2_ACCOUNT#__}" = "${RCLONE_CONFIG_B2_ACCOUNT}" ]; then + DEST="b2:${B2_BUCKET}/moon-household-budget" + if rclone copyto "${ARCHIVE}" "${DEST}/snapshots/moon-data-${STAMP}.tar.gz" 2>/tmp/rclone.err; then + rclone sync /data "${DEST}/current" 2>>/tmp/rclone.err || true + rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/snapshots" 2>/dev/null || true + echo "[backup ${STAMP}] pushed to B2 (${B2_BUCKET})" + else + echo "[backup ${STAMP}] B2 push FAILED: $(head -1 /tmp/rclone.err 2>/dev/null)" + fi +else + echo "[backup ${STAMP}] B2 not configured — local snapshot only" +fi + +echo "[backup ${STAMP}] done" diff --git a/bills.py b/bills.py new file mode 100644 index 0000000..ec4d2c0 --- /dev/null +++ b/bills.py @@ -0,0 +1,79 @@ +import json +import os +from datetime import date, timedelta +import calendar +from storage import load_json, save_json + +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) +BILLS_FILE = os.path.join(DATA_DIR, "bills.json") + + +def load_bills(): + return load_json(BILLS_FILE, []) + + +def save_bills(bills): + save_json(BILLS_FILE, bills) + + +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/changelog.json b/changelog.json new file mode 100644 index 0000000..b62fdb7 --- /dev/null +++ b/changelog.json @@ -0,0 +1,26 @@ +[ + { + "version": "0.2.0", + "date": "2026-06-06", + "title": "Automatic version + changelog on every update", + "changes": [ + "Every app update now automatically bumps the version and adds a 'What's New' entry, so you always see what changed.", + "Added a one-command release tool and wrote the process into the project so any future change stays documented." + ] + }, + { + "version": "0.1.0", + "date": "2026-06-06", + "title": "First tracked release", + "changes": [ + "Crash-safe data storage \u2014 saves are now atomic and a corrupted file can no longer take the app down.", + "Transactions moved to a SQLite database: faster, and safe when edited from two tabs at once. Existing transactions were migrated automatically.", + "New Settings page (\u2699\ufe0f) to store your Gmail login and reminder preferences securely \u2014 no passwords in the code anymore.", + "Suggestions & feedback (\ud83d\udca1) is now reachable from every page; what you send becomes a tracked issue.", + "Bill email reminders now use the Gmail login from Settings.", + "Automatic daily backups of all your data (local for now; Backblaze cloud once a bucket is set up).", + "Fixed the Monthly view crashing on the budget total.", + "Added this version number and changelog." + ] + } +] diff --git a/debts.py b/debts.py new file mode 100644 index 0000000..b6e7727 --- /dev/null +++ b/debts.py @@ -0,0 +1,187 @@ +import json +import os +from datetime import date +import math +from storage import load_json, save_json + +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) +DEBTS_FILE = os.path.join(DATA_DIR, "debts.json") +DEBT_LOG_FILE = os.path.join(DATA_DIR, "debt_log.json") + +DEBT_CATEGORIES = ["Credit Cards", "BNPL", "Medical Payment Plans"] + +BNPL_FREQUENCIES = { + "biweekly": 14, + "monthly": 30, + "4-payment": 14, +} + + +def simulate_payoff(debts, extra_monthly, method="avalanche"): + import copy + from datetime import date + from dateutil.relativedelta import relativedelta + + # Only credit cards and medical debts with a balance + active = [d for d in debts if not d.get("is_bnpl") and d.get("active", True) + and float(d.get("current_balance", 0)) > 0] + if not active: + return [], 0, 0, 0 + + # Build working copies + working = [] + for d in active: + working.append({ + "id": d["id"], + "name": d["name"], + "owner": d.get("owner", ""), + "balance": float(d.get("current_balance", 0)), + "apr": float(d.get("apr", 0)), + "minimum": float(d.get("minimum", 0)), + "paid_off_month": None, + "total_interest": 0, + "total_paid": 0, + }) + + total_minimum = sum(w["minimum"] for w in working) + extra = max(extra_monthly - total_minimum, 0) + + current_month = date.today().replace(day=1) + total_interest = 0 + months = 0 + max_months = 600 + + while any(w["balance"] > 0 for w in working) and months < max_months: + months += 1 + current_month_label = current_month.strftime("%b %Y") + + # Sort target debt + unpaid = [w for w in working if w["balance"] > 0] + if method == "avalanche": + unpaid.sort(key=lambda x: -x["apr"]) + else: + unpaid.sort(key=lambda x: x["balance"]) + + # Apply interest and minimums + for w in unpaid: + if w["balance"] <= 0: + continue + monthly_rate = (w["apr"] / 100) / 12 + interest = round(w["balance"] * monthly_rate, 2) + w["balance"] = round(w["balance"] + interest, 2) + w["total_interest"] += interest + total_interest += interest + payment = min(w["minimum"], w["balance"]) + w["balance"] = round(w["balance"] - payment, 2) + w["total_paid"] += payment + + # Apply extra to target + remaining_extra = extra + for w in unpaid: + if w["balance"] <= 0: + continue + extra_payment = min(remaining_extra, w["balance"]) + w["balance"] = round(w["balance"] - extra_payment, 2) + w["total_paid"] += extra_payment + remaining_extra -= extra_payment + if remaining_extra <= 0: + break + + # Roll over freed minimums as extra for next debt + for w in working: + if w["balance"] <= 0 and w["paid_off_month"] is None: + w["paid_off_month"] = current_month_label + extra += w["minimum"] + + from datetime import timedelta + current_month = (current_month.replace(day=28) + timedelta(days=4)).replace(day=1) + + return working, round(total_interest, 2), months, round(total_minimum + extra_monthly, 2) + + +def bnpl_payoff_date(payments_made, total_payments, frequency_days): + from datetime import date, timedelta + today = date.today() + remaining = total_payments - payments_made + if remaining <= 0: + return None, 0 + days = remaining * frequency_days + payoff = today + timedelta(days=days) + return payoff.strftime("%b %Y"), remaining + +OWNER_COLORS = { + "Bonna": "#D4B8C5", + "Tony": "#A0B8C5", + "Both": "#B8A8D4", +} + + +def load_debts(): + return load_json(DEBTS_FILE, []) + + +def save_debts(debts): + save_json(DEBTS_FILE, debts) + + +def load_debt_log(): + return load_json(DEBT_LOG_FILE, {}) + + +def save_debt_log(log): + save_json(DEBT_LOG_FILE, log) + + +def month_key(year, month): + return f"{year}-{month:02d}" + + +def monthly_interest(balance, apr): + if not apr or apr <= 0: + return 0 + return round(balance * (apr / 100) / 12, 2) + + +def payoff_months(balance, apr, monthly_payment, monthly_charges=0): + if monthly_payment <= 0: + return None + if apr and apr > 0: + monthly_rate = (apr / 100) / 12 + else: + monthly_rate = 0 + + b = balance + months = 0 + while b > 0 and months < 600: + b = b + (b * monthly_rate) + monthly_charges - monthly_payment + months += 1 + if monthly_charges == 0 and b >= balance: + return None + return months if months < 600 else None + + +def get_debt_month(debt_id, year, month): + log = load_debt_log() + mk = month_key(year, month) + return log.get(mk, {}).get(debt_id, {}) + + +def update_debt_month(debt_id, year, month, data): + log = load_debt_log() + mk = month_key(year, month) + log.setdefault(mk, {})[debt_id] = data + save_debt_log(log) + + +def compute_ending_balance(debt, year, month): + mk = month_key(year, month) + log = load_debt_log() + entry = log.get(mk, {}).get(debt["id"], {}) + + starting = entry.get("starting_balance", debt.get("current_balance", 0)) + interest = entry.get("interest") if entry.get("interest") is not None else monthly_interest(starting, debt.get("apr", 0)) + new_charges = entry.get("new_charges", 0) + payment = entry.get("payment", 0) + + ending = round(float(starting) + float(interest) + float(new_charges) - float(payment), 2) + return max(ending, 0) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dd25b32 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# Moon Household Budget — self-contained stack. +# app : the Python web app (JSON-file storage on the ./data volume) +# backup : sidecar that tar.gz-snapshots ./data locally + optionally to B2 +# +# 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: Dockerfile + container_name: moon-household-budget + restart: unless-stopped + ports: + - "8052:8000" # http://192.168.0.5:8052 + env_file: .env + volumes: + - ./data:/data # budget JSON files + uploaded images live here + healthcheck: + test: ["CMD-SHELL", "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 — never writes app data + - /mnt/user/Backup/moon-household-budget:/backups # local snapshot target diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..f3ee8ef --- /dev/null +++ b/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/income.py b/income.py new file mode 100644 index 0000000..63e89ea --- /dev/null +++ b/income.py @@ -0,0 +1,79 @@ +import json +import os +from datetime import date +from storage import load_json, save_json + +DATA_DIR = os.environ.get("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(): + return load_json(INCOME_FILE, []) + + +def save_income_log(log): + save_json(INCOME_FILE, log) + + +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/mortgage.py b/mortgage.py new file mode 100644 index 0000000..4e3355a --- /dev/null +++ b/mortgage.py @@ -0,0 +1,121 @@ +import json +import os +from datetime import date +from dateutil.relativedelta import relativedelta +import math +from storage import load_json, save_json + +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) +MORTGAGE_FILE = os.path.join(DATA_DIR, "mortgage.json") + + +def load_mortgage(): + return load_json(MORTGAGE_FILE, {}) + + +def save_mortgage(data): + save_json(MORTGAGE_FILE, data) + + +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/release.py b/release.py new file mode 100644 index 0000000..9d9044b --- /dev/null +++ b/release.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Cut a release for Moon Household Budget. + +Usage: + python release.py "" "" ... \\ + [--title "Short title"] [--date YYYY-MM-DD] + +Prepends a new entry to changelog.json (the single source of truth that drives +__version__, the footer version chip, and the /changelog page) and regenerates +CHANGELOG.md to match. + +Versioning: semver-ish — bump the patch (0.1.0 -> 0.1.1) for fixes, the minor +(0.1.0 -> 0.2.0) for new features. Run this for EVERY deploy (see CLAUDE.md). +""" +import json +import os +import sys +from datetime import date + +HERE = os.path.dirname(os.path.abspath(__file__)) +CHANGELOG_JSON = os.path.join(HERE, "changelog.json") +CHANGELOG_MD = os.path.join(HERE, "CHANGELOG.md") + + +def load(): + try: + with open(CHANGELOG_JSON) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return [] + + +def render_md(data): + lines = [ + "# Changelog", + "", + "All notable changes to Moon Household Budget. Newest first.", + "Generated from changelog.json by release.py — do not edit by hand.", + "", + ] + for rel in data: + head = f"## {rel['version']} — {rel.get('date', '')}" + if rel.get("title"): + head += f" — {rel['title']}" + lines += [head, ""] + lines += [f"- {c}" for c in rel.get("changes", [])] + lines.append("") + return "\n".join(lines) + + +def main(argv): + if len(argv) < 2: + print(__doc__) + return 1 + + version = argv[1] + title, rel_date, changes = "", date.today().isoformat(), [] + i = 2 + while i < len(argv): + if argv[i] == "--title" and i + 1 < len(argv): + title = argv[i + 1]; i += 2 + elif argv[i] == "--date" and i + 1 < len(argv): + rel_date = argv[i + 1]; i += 2 + else: + changes.append(argv[i]); i += 1 + + if not changes: + print("error: provide at least one change bullet") + return 1 + + data = load() + if data and data[0].get("version") == version: + print(f"error: version {version} is already the latest entry") + return 1 + + entry = {"version": version, "date": rel_date} + if title: + entry["title"] = title + entry["changes"] = changes + data.insert(0, entry) + + with open(CHANGELOG_JSON, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + with open(CHANGELOG_MD, "w") as f: + f.write(render_md(data)) + + print(f"released v{version} ({rel_date}) with {len(changes)} change(s)") + print(" -> changelog.json + CHANGELOG.md updated. Now commit + deploy.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/reminders.py b/reminders.py new file mode 100644 index 0000000..8dbaf73 --- /dev/null +++ b/reminders.py @@ -0,0 +1,140 @@ +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 +from settings import get_setting + + +def _gmail(): + """Resolve Gmail creds + recipient from Settings (env-var fallback).""" + email = get_setting("gmail_address", env="GMAIL_ADDRESS", default="") + pw = get_setting("gmail_app_password", env="GMAIL_APP_PASSWORD", default="") + recipient = get_setting("reminder_recipient", default="") or email + return email, pw, recipient + + +def send_email(subject, body_html, body_text): + email, pw, recipient = _gmail() + if not (email and pw): + print("Gmail not configured in Settings — skipping email reminder.") + return + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"Moon Household Budget <{email}>" + msg["To"] = recipient + 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, pw) + server.sendmail(email, recipient, msg.as_string()) + + +def mac_notification(title, message): + # macOS-only; no-op (don't crash) when osascript isn't available, e.g. in + # the Linux container. + script = f'display notification "{message}" with title "{title}" sound name "Default"' + try: + subprocess.run(["osascript", "-e", script], check=False) + except (FileNotFoundError, OSError): + pass + + +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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c99dc38 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Flask==3.1.3 +Werkzeug==3.1.8 +Jinja2==3.1.6 +click==8.1.8 +itsdangerous==2.2.0 +MarkupSafe==3.0.3 +pdfplumber==0.11.8 +python-dateutil +requests +gunicorn +webauthn==2.7.1 diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..c20c2d5 --- /dev/null +++ b/settings.py @@ -0,0 +1,52 @@ +"""App settings + secret storage (Gmail credentials, reminder prefs, etc.). + +Stored as JSON on the data volume (DATA_DIR/settings.json) via the atomic +storage layer — so it is NEVER committed to source control. Secrets resolve +with an environment-variable fallback, so a value can come from the settings +page OR from the container's .env. + +This is what lets us pull the hardcoded Gmail app password out of reminders.py +(issue #2): the password lives in settings.json on the volume instead. +""" +import os +from storage import load_json, save_json, DATA_DIR + +SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") + +# Keys holding secrets — their values are never rendered back to the browser. +SECRET_KEYS = {"gmail_app_password"} + +DEFAULTS = { + "gmail_address": "", + "gmail_app_password": "", + "reminder_recipient": "", # blank => send to gmail_address + "reminders_enabled": True, +} + + +def load_settings(): + data = load_json(SETTINGS_FILE, {}) or {} + merged = dict(DEFAULTS) + merged.update(data) + return merged + + +def save_settings(data): + save_json(SETTINGS_FILE, data) + + +def get_setting(key, env=None, default=""): + """Resolve a setting: stored value first, then env var, then default.""" + val = load_settings().get(key) + if val not in (None, ""): + return val + if env: + env_val = os.environ.get(env) + if env_val not in (None, ""): + return env_val + return default + + +def is_set(key): + """True if a (typically secret) value is present, without revealing it.""" + return bool(load_settings().get(key)) diff --git a/sinking.py b/sinking.py new file mode 100644 index 0000000..1b13622 --- /dev/null +++ b/sinking.py @@ -0,0 +1,58 @@ +import copy +import json +import os +from datetime import date +from storage import load_json, save_json + +DATA_DIR = os.environ.get("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(): + funds = load_json(SINKING_FILE, None) + if funds is None: + # First run — seed with a copy of the defaults (never hand out the + # module-level list, which callers mutate in place). + funds = copy.deepcopy(DEFAULT_FUNDS) + save_funds(funds) + return funds + + +def save_funds(funds): + save_json(SINKING_FILE, funds) + + +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/storage.py b/storage.py new file mode 100644 index 0000000..06d4748 --- /dev/null +++ b/storage.py @@ -0,0 +1,50 @@ +"""Crash-safe JSON persistence shared across all data modules. + +- load_json: returns `default` if the file is missing, empty, or corrupt + (e.g. a partial/interrupted write). A corrupt file is moved aside to + `.corrupt` so it can be inspected rather than silently overwritten. +- save_json: atomic write — serialize to a temp file in the same directory, + fsync, then os.replace() (atomic on POSIX). An interrupted write can never + leave a truncated or empty target file. + +This is the durability backbone for the app's JSON storage (issues #11/#12). +""" +import json +import os +import tempfile + +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) + + +def load_json(path, default): + if not os.path.exists(path): + return default + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, ValueError): + # Corrupt/partial file — preserve it for inspection, fall back to default + # instead of crashing the request. + try: + os.replace(path, path + ".corrupt") + except OSError: + pass + return default + + +def save_json(path, data): + directory = os.path.dirname(path) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise diff --git a/suggestions/gitea.py b/suggestions/gitea.py new file mode 100644 index 0000000..0c966a7 --- /dev/null +++ b/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/suggestions/routes_fastapi.py b/suggestions/routes_fastapi.py new file mode 100644 index 0000000..0b5bc7c --- /dev/null +++ b/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/suggestions/routes_flask.py b/suggestions/routes_flask.py new file mode 100644 index 0000000..cacb112 --- /dev/null +++ b/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/suggestions/static/suggestions.js b/suggestions/static/suggestions.js new file mode 100644 index 0000000..e9d6ad3 --- /dev/null +++ b/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/suggestions/templates/suggestions.html b/suggestions/templates/suggestions.html new file mode 100644 index 0000000..54f8fd0 --- /dev/null +++ b/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/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..504285e --- /dev/null +++ b/templates/base.html @@ -0,0 +1,79 @@ + + + + + + 🌙 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..722b40a --- /dev/null +++ b/templates/bills.html @@ -0,0 +1,228 @@ +{% 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/changelog.html b/templates/changelog.html new file mode 100644 index 0000000..93030a1 --- /dev/null +++ b/templates/changelog.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} +
+

📒 What's New

+

Current version: v{{ app_version }}

+ + {% for rel in changelog %} +
+
+

v{{ rel.version }}

+ {{ rel.date }}{% if rel.title %} · {{ rel.title }}{% endif %} +
+
    + {% for c in rel.changes %}
  • {{ c }}
  • {% endfor %} +
+
+ {% 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/enroll.html b/templates/enroll.html new file mode 100644 index 0000000..9a36e65 --- /dev/null +++ b/templates/enroll.html @@ -0,0 +1,65 @@ + + + + + + Add a device · Moon Household Budget + + + + +
+

🌙 Add this device

+

Create a passkey (Touch ID / Face ID / security key) for this device.

+ + {% if needs_code %}{% endif %} + +
+

Back to sign in

+
+ + + 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/login.html b/templates/login.html new file mode 100644 index 0000000..413bf51 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,57 @@ + + + + + + Sign in · Moon Household Budget + + + + +
+

🌙 Moon Household Budget

+

Sign in with your passkey

+ +
+

Set up a new device

+
+ + + diff --git a/templates/month.html b/templates/month.html new file mode 100644 index 0000000..fedfd0c --- /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()|sum|money }}{% 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/settings.html b/templates/settings.html new file mode 100644 index 0000000..5c2c505 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block content %} +
+

⚙️ Settings

+

+ Secrets are stored on the server (in the data volume) and are never shown + here once saved. Leave a password field blank to keep the current value. +

+ + {% if saved %} +
+ ✓ Settings saved. +
+ {% endif %} + +
+
+ 📧 Email reminders (Gmail) + + + + + + + + +
+ + +
+ +
+ 🔑 Passkeys (sign-in) +

+ Login is currently {{ 'ON' if auth_enabled else 'OFF' }}. + Devices enrolled with a passkey (Touch ID / Face ID / security key): +

+ {% if passkeys %} +
    + {% for d in passkeys %} +
  • + 🔑 {{ d.label }} +
    + + +
    +
  • + {% endfor %} +
+ {% else %} +

No devices enrolled yet.

+ {% endif %} + + Add a device +
+
+{% 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 %} diff --git a/txns_db.py b/txns_db.py new file mode 100644 index 0000000..cd035a0 --- /dev/null +++ b/txns_db.py @@ -0,0 +1,159 @@ +"""SQLite-backed storage for transactions (the large, query-heavy table). + +Drop-in replacement for the old JSON list: get_transactions() returns the same +list-of-dicts shape (including a "splits" list per row) and save_transactions() +takes that same list. This keeps app.py's existing logic unchanged while giving +us ACID writes (no corruption / no lost-update race) and indexed queries. + +The DB lives on the data volume (DATA_DIR/transactions.db) so it's self- +contained and captured by the existing tar.gz backup. WAL is checkpointed after +every write so the .db file stays authoritative for the read-only backup. +""" +import os +import sqlite3 +from storage import DATA_DIR, load_json + +DB_FILE = os.path.join(DATA_DIR, "transactions.db") + +# Transaction dict keys persisted as columns (everything except the internal seq). +COLUMNS = ["id", "date", "description", "amount", "category", "subcategory", + "account", "notes", "reviewed", "ignored", "car_id", "txn_type"] + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS transactions ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT UNIQUE NOT NULL, + date TEXT, + description TEXT, + amount REAL, + category TEXT, + subcategory TEXT, + account TEXT, + notes TEXT, + reviewed INTEGER NOT NULL DEFAULT 0, + ignored INTEGER NOT NULL DEFAULT 0, + car_id TEXT, + txn_type TEXT +); +CREATE TABLE IF NOT EXISTS transaction_splits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txn_id TEXT NOT NULL, + category TEXT, + subcategory TEXT, + amount REAL, + note TEXT +); +CREATE INDEX IF NOT EXISTS idx_txn_reviewed ON transactions(reviewed); +CREATE INDEX IF NOT EXISTS idx_txn_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_txn_cat ON transactions(reviewed, ignored, category); +CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(txn_id); +""" + + +def _connect(): + conn = sqlite3.connect(DB_FILE, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + return conn + + +def init_db(): + conn = _connect() + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + + +def get_transactions(): + """Return all transactions as a list of dicts (import order), each with a + 'splits' list — identical shape to the old JSON storage.""" + conn = _connect() + try: + rows = conn.execute( + "SELECT id, date, description, amount, category, subcategory, account, " + "notes, reviewed, ignored, car_id, txn_type FROM transactions ORDER BY seq" + ).fetchall() + srows = conn.execute( + "SELECT txn_id, category, subcategory, amount, note " + "FROM transaction_splits ORDER BY id" + ).fetchall() + finally: + conn.close() + + splits_by_id = {} + for s in srows: + splits_by_id.setdefault(s["txn_id"], []).append({ + "category": s["category"], "subcategory": s["subcategory"], + "amount": s["amount"], "note": s["note"], + }) + + out = [] + for r in rows: + t = dict(r) + t["reviewed"] = bool(r["reviewed"]) + t["ignored"] = bool(r["ignored"]) + t["splits"] = splits_by_id.get(r["id"], []) + out.append(t) + return out + + +def save_transactions(transactions): + """Persist the full transaction list atomically (replace-all in one txn).""" + conn = _connect() + try: + conn.execute("DELETE FROM transaction_splits") + conn.execute("DELETE FROM transactions") + for t in transactions: + conn.execute( + "INSERT OR IGNORE INTO transactions " + "(id, date, description, amount, category, subcategory, account, " + "notes, reviewed, ignored, car_id, txn_type) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (t.get("id"), t.get("date"), t.get("description"), + float(t.get("amount") or 0), t.get("category"), t.get("subcategory"), + t.get("account"), t.get("notes"), + 1 if t.get("reviewed") else 0, 1 if t.get("ignored") else 0, + t.get("car_id"), t.get("txn_type")), + ) + for s in (t.get("splits") or []): + conn.execute( + "INSERT INTO transaction_splits (txn_id, category, subcategory, amount, note) " + "VALUES (?,?,?,?,?)", + (t.get("id"), s.get("category"), s.get("subcategory", ""), + float(s.get("amount") or 0), s.get("note", "")), + ) + conn.commit() + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + finally: + conn.close() + + +def count_unreviewed(): + """Fast count for the per-request nav badge (was a full JSON parse).""" + conn = _connect() + try: + return conn.execute( + "SELECT COUNT(*) FROM transactions WHERE reviewed = 0" + ).fetchone()[0] + finally: + conn.close() + + +def migrate_from_json(json_path): + """One-time import of an existing transactions.json into SQLite. + No-op once the DB has any rows, so it's safe to call on every startup.""" + conn = _connect() + try: + existing = conn.execute("SELECT COUNT(*) FROM transactions").fetchone()[0] + finally: + conn.close() + if existing > 0: + return 0 + data = load_json(json_path, []) + if not data: + return 0 + save_transactions(data) + return len(data) diff --git a/version.py b/version.py new file mode 100644 index 0000000..f14902a --- /dev/null +++ b/version.py @@ -0,0 +1,18 @@ +"""App version + changelog, sourced from changelog.json (single source of truth). + +Cut releases with: python release.py "" ... +That prepends an entry to changelog.json and regenerates CHANGELOG.md. The +footer version chip and the /changelog page read from here automatically. +""" +import json +import os + +_CHANGELOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "changelog.json") + +try: + with open(_CHANGELOG_PATH) as f: + CHANGELOG = json.load(f) +except (OSError, json.JSONDecodeError): + CHANGELOG = [] + +__version__ = CHANGELOG[0]["version"] if CHANGELOG else "0.0.0"