From a3b91f6d6b9b33e1d348a699a6c5da560af3a594 Mon Sep 17 00:00:00 2001 From: Bonna Date: Sat, 6 Jun 2026 19:12:43 -0500 Subject: [PATCH] Initial commit: moon-household-budget --- .claude/settings.local.json | 50 + .dockerignore | 15 + .gitignore | 20 + CHANGELOG.md | 20 + CLAUDE.md | 48 + Dockerfile | 29 + app.py | 2900 ++++++++++++++++++++++++ assets.py | 87 + auth.py | 94 + auth_routes.py | 198 ++ backup/Dockerfile | 13 + backup/backup.sh | 44 + bills.py | 79 + categories.py | 37 + changelog.json | 26 + debts.py | 187 ++ docker-compose.yml | 33 + entrypoint.sh | 19 + income.py | 79 + mortgage.py | 121 + release.py | 94 + reminders.py | 140 ++ requirements.txt | 11 + settings.py | 52 + sinking.py | 58 + static/style.css | 1737 ++++++++++++++ storage.py | 50 + suggestions/gitea.py | 116 + suggestions/routes_fastapi.py | 121 + suggestions/routes_flask.py | 146 ++ suggestions/static/suggestions.js | 87 + suggestions/templates/suggestions.html | 46 + templates/assets.html | 486 ++++ templates/base.html | 79 + templates/bills.html | 228 ++ templates/calendar.html | 50 + templates/changelog.html | 19 + templates/debts.html | 774 +++++++ templates/edit.html | 234 ++ templates/enroll.html | 65 + templates/import.html | 50 + templates/income.html | 277 +++ templates/index.html | 385 ++++ templates/login.html | 57 + templates/month.html | 244 ++ templates/mortgage.html | 157 ++ templates/net_worth.html | 225 ++ templates/review.html | 396 ++++ templates/settings.html | 83 + templates/sinking.html | 175 ++ templates/strategy.html | 116 + templates/summary.html | 71 + templates/taxes.html | 187 ++ templates/transactions.html | 168 ++ templates/weekly.html | 484 ++++ txns_db.py | 159 ++ version.py | 18 + 57 files changed, 11944 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 assets.py create mode 100644 auth.py create mode 100644 auth_routes.py create mode 100644 backup/Dockerfile create mode 100644 backup/backup.sh create mode 100644 bills.py create mode 100644 categories.py create mode 100644 changelog.json create mode 100644 debts.py create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 income.py create mode 100644 mortgage.py create mode 100644 release.py create mode 100644 reminders.py create mode 100644 requirements.txt create mode 100644 settings.py create mode 100644 sinking.py create mode 100644 static/style.css create mode 100644 storage.py create mode 100644 suggestions/gitea.py create mode 100644 suggestions/routes_fastapi.py create mode 100644 suggestions/routes_flask.py create mode 100644 suggestions/static/suggestions.js create mode 100644 suggestions/templates/suggestions.html create mode 100644 templates/assets.html create mode 100644 templates/base.html create mode 100644 templates/bills.html create mode 100644 templates/calendar.html create mode 100644 templates/changelog.html create mode 100644 templates/debts.html create mode 100644 templates/edit.html create mode 100644 templates/enroll.html create mode 100644 templates/import.html create mode 100644 templates/income.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/month.html create mode 100644 templates/mortgage.html create mode 100644 templates/net_worth.html create mode 100644 templates/review.html create mode 100644 templates/settings.html create mode 100644 templates/sinking.html create mode 100644 templates/strategy.html create mode 100644 templates/summary.html create mode 100644 templates/taxes.html create mode 100644 templates/transactions.html create mode 100644 templates/weekly.html create mode 100644 txns_db.py create mode 100644 version.py 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"