Initial commit: moon-household-budget

This commit is contained in:
Bonna
2026-06-06 19:12:43 -05:00
commit a3b91f6d6b
57 changed files with 11944 additions and 0 deletions

View File

@@ -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 ' *)"
]
}
}

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.gitignore
__pycache__/
*.pyc
.venv/
venv/
env/
.env
data/
*.sqlite
*.sqlite3
*.db
.DS_Store
node_modules/
bonnasclaude.md

20
.gitignore vendored Normal file
View File

@@ -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/

20
CHANGELOG.md Normal file
View File

@@ -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.

48
CLAUDE.md Normal file
View File

@@ -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 <version> "<plain-language change>" "<another change>" [--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`.

29
Dockerfile Normal file
View File

@@ -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"]

2900
app.py Normal file

File diff suppressed because it is too large Load Diff

87
assets.py Normal file
View File

@@ -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)

94
auth.py Normal file
View File

@@ -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)

198
auth_routes.py Normal file
View File

@@ -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))

13
backup/Dockerfile Normal file
View File

@@ -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"]

44
backup/backup.sh Normal file
View File

@@ -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"

79
bills.py Normal file
View File

@@ -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 (17)"
elif day <= 14:
return "Week 2 (814)"
elif day <= 21:
return "Week 3 (1521)"
else:
return "Week 4 (22end)"
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)]

37
categories.py Normal file
View File

@@ -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
}

26
changelog.json Normal file
View File

@@ -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."
]
}
]

187
debts.py Normal file
View File

@@ -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)

33
docker-compose.yml Normal file
View File

@@ -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

19
entrypoint.sh Normal file
View File

@@ -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 "$@"

79
income.py Normal file
View File

@@ -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)

121
mortgage.py Normal file
View File

@@ -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

94
release.py Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Cut a release for Moon Household Budget.
Usage:
python release.py <version> "<change 1>" "<change 2>" ... \\
[--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))

140
reminders.py Normal file
View File

@@ -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"""
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;">{b['name']}</td>
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;font-weight:600;">{amt}</td>
<td style="padding:8px 12px;border-bottom:1px solid #EDE8E2;color:#7A6E68;">{cat}</td>
</tr>"""
total_row = f"${total:.2f}" if total else ""
body_html = f"""
<div style="font-family:'Helvetica Neue',Arial,sans-serif;max-width:520px;margin:0 auto;background:#FAF7F2;padding:24px;border-radius:12px;">
<h2 style="font-size:22px;color:#3A3330;margin-bottom:4px;">🌙 Moon Household Budget</h2>
<p style="color:#7A6E68;margin-bottom:20px;font-size:14px;">Bill reminder — due <strong>{label}</strong></p>
<table style="width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(80,60,50,0.08);">
<thead>
<tr style="background:#F5F1EC;">
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Bill</th>
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Amount</th>
<th style="text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#B0A89E;">Category</th>
</tr>
</thead>
<tbody>{rows}</tbody>
{f'<tfoot><tr><td style="padding:8px 12px;font-weight:700;" colspan="2">Total: {total_row}</td><td></td></tr></tfoot>' if total_row else ''}
</table>
<p style="color:#B0A89E;font-size:12px;margin-top:20px;text-align:center;">Moon Household Budget · sent to bonna61@gmail.com</p>
</div>"""
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.")

11
requirements.txt Normal file
View File

@@ -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

52
settings.py Normal file
View File

@@ -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))

58
sinking.py Normal file
View File

@@ -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: 36 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)

1737
static/style.css Normal file

File diff suppressed because it is too large Load Diff

50
storage.py Normal file
View File

@@ -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
`<path>.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

116
suggestions/gitea.py Normal file
View File

@@ -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}_"

View File

@@ -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)

146
suggestions/routes_flask.py Normal file
View File

@@ -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/<name> (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/<int:number>/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

View File

@@ -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 = '<p class="muted">No open suggestions yet.</p>';
return;
}
list.innerHTML = issues.map((i) => `
<div class="card">
<div class="issue-title">#${i.number} ${escapeHtml(i.title)}</div>
<div class="issue-meta">${i.comments} comment(s) ·
<a href="${i.html_url}" target="_blank" rel="noopener">view on Gitea</a></div>
</div>`).join("");
} catch (e) {
list.innerHTML = '<p class="muted">Could not load suggestions.</p>';
}
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Suggestions · Moon Household Budget</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1c1c1e; }
h1 { font-size: 1.4rem; }
.card { border: 1px solid #e3e3e6; border-radius: 12px; padding: 1rem; margin: .75rem 0; }
input, textarea { width: 100%; box-sizing: border-box; padding: .6rem; border-radius: 8px;
border: 1px solid #cfcfd4; font: inherit; margin-bottom: .5rem; }
textarea { min-height: 100px; resize: vertical; }
button { background: #4338ca; color: #fff; border: 0; border-radius: 8px;
padding: .6rem 1rem; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
.muted { color: #6b6b70; font-size: .85rem; }
.issue-title { font-weight: 600; }
.issue-meta { color: #6b6b70; font-size: .8rem; }
.notice { background: #fff8e1; border: 1px solid #f0e3a8; padding: .75rem; border-radius: 8px; }
a { color: #4338ca; }
</style>
</head>
<body>
<h1>💡 Suggestions &amp; Feedback</h1>
<p class="muted">Ideas, bugs, “can it also do…?” — they go straight to Tony.</p>
{% if not configured %}
<div class="notice">Feedback isnt set up yet (Gitea env vars missing). Once configured, this page goes live.</div>
{% else %}
<div class="card">
<input id="s-title" placeholder="Short title (required)" />
<textarea id="s-body" placeholder="Details… paste a screenshot to attach it."></textarea>
<div style="display:flex; gap:.5rem; align-items:center;">
<button id="s-send">Send suggestion</button>
<span id="s-status" class="muted"></span>
</div>
</div>
<h2 style="font-size:1.1rem;">Open suggestions</h2>
<div id="s-list"><p class="muted">Loading…</p></div>
{% endif %}
<script src="{{ url_for('suggestions.static', filename='suggestions.js') }}"></script>
</body>
</html>

486
templates/assets.html Normal file
View File

@@ -0,0 +1,486 @@
{% extends "base.html" %}
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<div class="page-header">
<h1>Assets</h1>
<input type="text" id="asset-search" placeholder="Search assets…" class="budget-input" style="width:220px;" oninput="filterAssets(this.value)">
</div>
<!-- Net worth summary -->
{% if net_worth_assets %}
<div class="sinking-total-bar" style="margin-bottom:1.75rem;">
<div>
<div class="sinking-total-label">Total Tracked Asset Value</div>
<div class="sinking-total-amount">${{ "{:,.2f}".format(net_worth_assets) }}</div>
</div>
<div class="sinking-total-note">Based on estimated / current values entered below</div>
</div>
{% endif %}
<!-- Add Asset button -->
<details class="add-debt-details" style="margin-bottom:2rem;">
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Add Asset</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<h3 style="margin-bottom:1rem;font-family:'DM Serif Display',serif;font-weight:400;">What type of asset?</h3>
<div class="asset-type-grid">
<!-- Vehicle -->
<details class="add-debt-details">
<summary class="asset-type-btn">🚗 Vehicle</summary>
<form method="POST" action="{{ url_for('vehicle_add') }}" style="margin-top:1rem;">
<div class="bill-form-grid">
<div class="form-group" style="grid-column:1/-1;">
<label>Vehicle Type</label>
<div style="display:flex;gap:1rem;margin-top:0.25rem;">
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:400;cursor:pointer;">
<input type="radio" name="vehicle_type" value="personal" checked> Personal / Daily Driver
</label>
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:400;cursor:pointer;">
<input type="radio" name="vehicle_type" value="flip"> Flip Car
</label>
</div>
</div>
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2020" required></div>
<div class="form-group"><label>Make</label><input type="text" name="make" placeholder="e.g. Toyota" required></div>
<div class="form-group"><label>Model</label><input type="text" name="model" placeholder="e.g. Camry" required></div>
<div class="form-group"><label>Trim</label><input type="text" name="trim" placeholder="e.g. LE, 2.5L"></div>
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
<div class="form-group"><label>Owner</label>
<select name="owner">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Estimated Current Value</label><input type="number" name="estimated_value" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
</div>
<button type="submit" class="btn btn-primary">Add Vehicle</button>
</form>
</details>
<!-- Investments -->
<details class="add-debt-details">
<summary class="asset-type-btn">📈 Investments</summary>
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
<input type="hidden" name="category" value="Investments">
<div class="bill-form-grid">
<div class="form-group"><label>Account / Asset Name</label><input type="text" name="name" placeholder="e.g. Roth IRA, 401k, Brokerage" required></div>
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
<div class="form-group"><label>Date Opened</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Current Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="e.g. Fidelity, employer match, contribution limit..."></div>
</div>
<button type="submit" class="btn btn-primary">Add Investment</button>
</form>
</details>
<!-- Real Estate -->
<details class="add-debt-details">
<summary class="asset-type-btn">🏠 Real Estate</summary>
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
<input type="hidden" name="category" value="Real Estate">
<div class="bill-form-grid">
<div class="form-group"><label>Description</label><input type="text" name="name" placeholder="e.g. Primary Home, Rental Property" required></div>
<div class="form-group"><label>Owner</label><select name="owner"><option value="Both">Both</option><option value="Bonna">Bonna</option><option value="Tony">Tony</option></select></div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Current Estimated Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Address, details..."></div>
</div>
<button type="submit" class="btn btn-primary">Add Real Estate</button>
</form>
</details>
<!-- Equipment -->
<details class="add-debt-details">
<summary class="asset-type-btn">🔧 Equipment</summary>
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
<input type="hidden" name="category" value="Equipment">
<div class="bill-form-grid">
<div class="form-group"><label>Item Name</label><input type="text" name="name" placeholder="e.g. Studio Camera, Stand Mixer" required></div>
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Current Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Serial #, warranty..."></div>
</div>
<button type="submit" class="btn btn-primary">Add Equipment</button>
</form>
</details>
<!-- Other Valuables -->
<details class="add-debt-details">
<summary class="asset-type-btn">✨ Other Valuables</summary>
<form method="POST" action="{{ url_for('other_asset_add') }}" style="margin-top:1rem;">
<input type="hidden" name="category" value="Other Valuables">
<div class="bill-form-grid">
<div class="form-group"><label>Item Name</label><input type="text" name="name" placeholder="e.g. Engagement Ring, Art" required></div>
<div class="form-group"><label>Owner</label><select name="owner"><option value="Bonna">Bonna</option><option value="Tony">Tony</option><option value="Both">Both</option></select></div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Current / Appraised Value</label><input type="number" name="value" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
</div>
<button type="submit" class="btn btn-primary">Add Item</button>
</form>
</details>
</div>
</div>
</details>
<!-- Vehicles -->
<section class="section">
<h2>Vehicles</h2>
<h3 class="asset-subheading">Personal</h3>
<div class="asset-grid">
{% for car in personal %}
<div class="asset-card">
<div class="asset-card-header">
<div>
<div class="asset-car-name">{{ car.display_name }}</div>
<div class="asset-car-trim muted">{{ car.trim }}{% if car.color %} · {{ car.color }}{% endif %}</div>
</div>
<span class="owner-tag" style="background:{{ owner_colors.get(car.owner, '#ddd') }};">{{ car.owner }}</span>
</div>
<div class="asset-stat-row">
<div class="asset-stat"><div class="asset-stat-label">Total Spent</div><div class="asset-stat-value">${{ car.total_spent | money }}</div></div>
<div class="asset-stat"><div class="asset-stat-label">Est. Value</div><div class="asset-stat-value">{% if car.estimated_value %}${{ "{:,.0f}".format(car.estimated_value) }}{% else %}<span class="muted"></span>{% endif %}</div></div>
<div class="asset-stat"><div class="asset-stat-label">Gas Logs</div><div class="asset-stat-value">{{ car.gas_log | length }}</div></div>
<div class="asset-stat"><div class="asset-stat-label">Service Logs</div><div class="asset-stat-value">{{ car.maintenance_log | length }}</div></div>
</div>
<details class="asset-details"><summary class="asset-details-toggle">+ Log Gas Fill-up</summary>
<form method="POST" action="{{ url_for('personal_car_log', car_id=car.id) }}" class="asset-log-form">
<input type="hidden" name="log_type" value="gas">
<div class="asset-log-grid">
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
<div class="form-group"><label>Amount Paid</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
<div class="form-group"><label>Gallons <span class="optional">(optional)</span></label><input type="number" name="gallons" step="0.001" placeholder="0.000"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Station, grade..."></div>
</div>
<button type="submit" class="btn btn-accept">Save</button>
</form>
</details>
<details class="asset-details"><summary class="asset-details-toggle">+ Log Maintenance / Repair</summary>
<form method="POST" action="{{ url_for('personal_car_log', car_id=car.id) }}" class="asset-log-form">
<input type="hidden" name="log_type" value="maintenance">
<div class="asset-log-grid">
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
<div class="form-group"><label>Amount</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
<div class="form-group"><label>Description</label><input type="text" name="description" placeholder="e.g. Oil change" required></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes" placeholder="Shop, details..."></div>
</div>
<button type="submit" class="btn btn-accept">Save</button>
</form>
</details>
{% set recent_gas = car.gas_log[-3:] | reverse | list %}
{% set recent_maint = car.maintenance_log[-3:] | reverse | list %}
{% if recent_gas or recent_maint %}
<details class="asset-details"><summary class="asset-details-toggle">Recent Activity</summary>
{% if recent_gas %}<div class="asset-log-section-label">Gas</div>{% for e in recent_gas %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{% if e.gallons %}{{ e.gallons }} gal{% endif %} {{ e.notes }}</span></div>{% endfor %}{% endif %}
{% if recent_maint %}<div class="asset-log-section-label" style="margin-top:0.5rem;">Maintenance</div>{% for e in recent_maint %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{{ e.get('description','') }} {{ e.notes }}</span></div>{% endfor %}{% endif %}
</details>
{% endif %}
<details class="asset-details"><summary class="asset-details-toggle">Edit Details</summary>
<form method="POST" action="{{ url_for('personal_car_update', car_id=car.id) }}" class="asset-log-form">
<div class="asset-log-grid">
<div class="form-group"><label>Color</label><input type="text" name="color" value="{{ car.color }}"></div>
<div class="form-group"><label>Owner</label><select name="owner">{% for o in ['Bonna','Tony','Both'] %}<option value="{{ o }}" {% if car.owner == o %}selected{% endif %}>{{ o }}</option>{% endfor %}</select></div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" value="{{ car.purchase_price or '' }}" placeholder="0.00"></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date" value="{{ car.purchase_date or '' }}"></div>
<div class="form-group"><label>Estimated Value (KBB)</label><input type="number" name="estimated_value" step="0.01" value="{{ car.estimated_value or '' }}" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes" value="{{ car.notes or '' }}"></div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</details>
</div>
{% endfor %}
</div>
{% if active_flips %}
<h3 class="asset-subheading" style="margin-top:1.5rem;">Flip Inventory</h3>
<div style="margin-bottom:1rem;">
<details class="add-debt-details">
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Flip Car</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<form method="POST" action="{{ url_for('flip_add') }}" class="bill-form">
<div class="bill-form-grid">
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2018" required></div>
<div class="form-group"><label>Make</label><input type="text" name="make" required></div>
<div class="form-group"><label>Model</label><input type="text" name="model" required></div>
<div class="form-group"><label>Trim</label><input type="text" name="trim"></div>
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00" required></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Personal Funds Used</label><input type="number" name="personal_contribution" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
</div>
<button type="submit" class="btn btn-powder-blue">Add Flip Car</button>
</form>
</div>
</details>
</div>
<div class="asset-grid">
{% for flip in active_flips %}
<div class="asset-card asset-flip-card">
<div class="asset-card-header">
<div><div class="asset-car-name">{{ flip.display_name }}</div><div class="asset-car-trim muted">{{ flip.trim }}{% if flip.color %} · {{ flip.color }}{% endif %}</div></div>
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="flip-badge">Flip</span>
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
onclick="openEditFlip('{{ flip.id }}','{{ flip.year }}','{{ flip.make }}','{{ flip.model }}','{{ flip.trim or '' }}','{{ flip.color or '' }}','{{ flip.purchase_price or 0 }}','{{ flip.purchase_date or '' }}','{{ flip.notes or '' }}')">
✏️ Edit
</button>
</div>
</div>
<div class="asset-stat-row">
<div class="asset-stat"><div class="asset-stat-label">Purchased</div><div class="asset-stat-value">${{ "{:,.0f}".format(flip.purchase_price) }}</div></div>
<div class="asset-stat"><div class="asset-stat-label">Total In</div><div class="asset-stat-value">${{ "{:,.0f}".format(flip.total_invested) }}</div></div>
<div class="asset-stat"><div class="asset-stat-label">Expenses</div><div class="asset-stat-value">{{ flip.expense_log | length }}</div></div>
</div>
<details class="asset-details"><summary class="asset-details-toggle">+ Log Personal Expense</summary>
<form method="POST" action="{{ url_for('flip_expense', flip_id=flip.id) }}" class="asset-log-form">
<div class="asset-log-grid">
<div class="form-group"><label>Date</label><input type="date" name="date" required></div>
<div class="form-group"><label>Amount</label><input type="number" name="amount" step="0.01" placeholder="0.00" required></div>
<div class="form-group"><label>Description</label><input type="text" name="description" placeholder="Parts, paint..." required></div>
</div>
<button type="submit" class="btn btn-accept">Save</button>
</form>
</details>
{% if flip.expense_log %}<details class="asset-details"><summary class="asset-details-toggle">Expense Log</summary>{% for e in flip.expense_log | reverse %}<div class="asset-log-entry"><span class="muted">{{ e.date }}</span><span>${{ e.amount | money }}</span><span class="muted">{{ e.description }}</span></div>{% endfor %}</details>{% endif %}
<details class="asset-details"><summary class="asset-details-toggle" style="color:#7A9E78;">✓ Mark as Sold</summary>
<form method="POST" action="{{ url_for('flip_sell', flip_id=flip.id) }}" class="asset-log-form">
<div class="asset-log-grid">
<div class="form-group"><label>Sale Price</label><input type="number" name="sale_price" step="0.01" placeholder="0.00" required></div>
<div class="form-group"><label>Sale Date</label><input type="date" name="sold_date" required></div>
</div>
<button type="submit" class="btn btn-primary">Mark Sold</button>
</form>
</details>
<form method="POST" action="{{ url_for('flip_delete', flip_id=flip.id) }}"
onsubmit="return confirm('Remove {{ flip.display_name }} from flips? This cannot be undone.');">
<button type="submit" class="debt-delete-btn">Remove</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<h3 class="asset-subheading" style="margin-top:1.5rem;">Flip Inventory</h3>
<details class="add-debt-details" style="margin-bottom:1rem;">
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Flip Car</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<form method="POST" action="{{ url_for('flip_add') }}" class="bill-form">
<div class="bill-form-grid">
<div class="form-group"><label>Year</label><input type="number" name="year" placeholder="e.g. 2018" required></div>
<div class="form-group"><label>Make</label><input type="text" name="make" required></div>
<div class="form-group"><label>Model</label><input type="text" name="model" required></div>
<div class="form-group"><label>Trim</label><input type="text" name="trim"></div>
<div class="form-group"><label>Color</label><input type="text" name="color"></div>
<div class="form-group"><label>Purchase Price</label><input type="number" name="purchase_price" step="0.01" placeholder="0.00" required></div>
<div class="form-group"><label>Purchase Date</label><input type="date" name="purchase_date"></div>
<div class="form-group"><label>Personal Funds Used</label><input type="number" name="personal_contribution" step="0.01" placeholder="0.00"></div>
<div class="form-group"><label>Notes</label><input type="text" name="notes"></div>
</div>
<button type="submit" class="btn btn-powder-blue">Add Flip Car</button>
</form>
</div>
</details>
<p class="muted">No active flips.</p>
{% endif %}
{% if sold_flips %}
<h3 class="asset-subheading" style="margin-top:1.5rem;">Sold Flips</h3>
<table class="txn-table txn-table-full">
<thead><tr><th>Car</th><th>Bought For</th><th>Total Invested</th><th>Sold For</th><th>Profit / Loss</th><th>Sold</th></tr></thead>
<tbody>
{% for flip in sold_flips %}
{% set profit = flip.profit %}
<tr>
<td>{{ flip.display_name }}{% if flip.color %} <span class="muted">{{ flip.color }}</span>{% endif %}</td>
<td class="amount">${{ "{:,.2f}".format(flip.purchase_price) }}</td>
<td class="amount">${{ "{:,.2f}".format(flip.total_invested) }}</td>
<td class="amount">${{ "{:,.2f}".format(flip.sale_price) }}</td>
<td class="amount {% if profit and profit > 0 %}accent{% elif profit and profit < 0 %}text-red{% endif %}">{% if profit is not none %}{{ '+' if profit > 0 else '' }}${{ "{:,.2f}".format(profit) }}{% else %}—{% endif %}</td>
<td class="muted">{{ flip.sold_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<!-- Investment tracking instructions -->
<div class="card-wide" style="margin-bottom:1.5rem;">
<h3 style="margin-bottom:0.5rem;">📈 How Investment Tracking Works</h3>
<p class="helper-text" style="margin-bottom:0.5rem;">Add each investment account (Roth IRA, 401k, brokerage, etc.) using the <strong>+ Add Asset</strong> button above and select <strong>Investments</strong>.</p>
<p class="helper-text" style="margin-bottom:0.5rem;">Once added, each card has an editable <strong>Current Value</strong> 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.</p>
<p class="helper-text">After two or more updates on different days, a <strong>growth chart</strong> will appear on the card automatically, showing your balance over time in green (growth) or red (decline) with the total change displayed.</p>
</div>
<!-- Other asset categories -->
{% for cat in asset_categories %}
<section class="section">
<h2>{{ cat }}</h2>
{% set cat_assets = by_category[cat] %}
{% if cat_assets %}
<div class="asset-grid">
{% for a in cat_assets %}
<div class="asset-card">
<div class="asset-card-header">
<div><div class="asset-car-name">{{ a.name }}</div>{% if a.notes %}<div class="asset-car-trim muted">{{ a.notes }}</div>{% endif %}</div>
<span class="owner-tag" style="background:{{ owner_colors.get(a.owner, '#ddd') }};">{{ a.owner }}</span>
</div>
<div class="asset-stat-row">
<div class="asset-stat"><div class="asset-stat-label">Paid</div><div class="asset-stat-value">{% if a.purchase_price %}${{ "{:,.0f}".format(a.purchase_price) }}{% else %}<span class="muted"></span>{% endif %}</div></div>
<div class="asset-stat">
<div class="asset-stat-label">Current Value</div>
<div class="asset-stat-value">
$<input type="number" step="0.01" min="0"
value="{{ a.value or 0 }}"
class="budget-input" style="width:90px;display:inline;"
onchange="updateAssetValue('{{ a.id }}', this.value)">
</div>
</div>
<div class="asset-stat"><div class="asset-stat-label">Date</div><div class="asset-stat-value muted" style="font-size:0.78rem;">{{ a.purchase_date or '—' }}</div></div>
</div>
{% if a.value_history and a.value_history | length > 1 %}
<div style="margin:0.75rem 0 0.5rem;">
<div style="font-size:0.72rem;color:var(--text-light);margin-bottom:0.25rem;">
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 %}
<span style="color:#5A9E68;margin-left:0.4rem;">▲ ${{ change | money }}</span>
{% else %}
<span style="color:#c07070;margin-left:0.4rem;">▼ ${{ change | abs | money }}</span>
{% endif %}
</div>
<canvas id="chart-{{ a.id }}" height="60"
data-labels="{{ a.value_history | map(attribute='date') | list | tojson }}"
data-values="{{ a.value_history | map(attribute='value') | list | tojson }}">
</canvas>
</div>
{% elif a.value_history and a.value_history | length == 1 %}
<div style="font-size:0.72rem;color:var(--text-light);margin:0.5rem 0;">Update value again next month to start seeing growth chart.</div>
{% endif %}
<form method="POST" action="{{ url_for('other_asset_delete', asset_id=a.id) }}"
onsubmit="return confirm('Remove {{ a.name }}?');">
<button type="submit" class="debt-delete-btn">Remove</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="muted" style="margin-bottom:0.5rem;">No {{ cat | lower }} added yet.</p>
{% endif %}
</section>
{% endfor %}
<!-- Edit Flip Modal -->
<div id="edit-flip-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
<h3 style="margin:0 0 1rem;" id="edit-flip-title">Edit Flip</h3>
<form method="POST" id="edit-flip-form" action="">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div class="form-group"><label style="font-size:0.85rem;">Year</label><input type="number" name="year" id="ef-year" class="budget-input" style="width:100%;" required></div>
<div class="form-group"><label style="font-size:0.85rem;">Make</label><input type="text" name="make" id="ef-make" class="budget-input" style="width:100%;" required></div>
<div class="form-group"><label style="font-size:0.85rem;">Model</label><input type="text" name="model" id="ef-model" class="budget-input" style="width:100%;" required></div>
<div class="form-group"><label style="font-size:0.85rem;">Trim</label><input type="text" name="trim" id="ef-trim" class="budget-input" style="width:100%;"></div>
<div class="form-group"><label style="font-size:0.85rem;">Color</label><input type="text" name="color" id="ef-color" class="budget-input" style="width:100%;"></div>
<div class="form-group"><label style="font-size:0.85rem;">Purchase Price</label><input type="number" step="0.01" name="purchase_price" id="ef-price" class="budget-input" style="width:100%;"></div>
<div class="form-group"><label style="font-size:0.85rem;">Purchase Date</label><input type="date" name="purchase_date" id="ef-date" class="budget-input" style="width:100%;"></div>
<div class="form-group"><label style="font-size:0.85rem;">Notes</label><input type="text" name="notes" id="ef-notes" class="budget-input" style="width:100%;"></div>
</div>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
<button type="button" class="btn btn-ghost" onclick="closeEditFlip()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function updateAssetValue(id, value) {
fetch('/assets/other/update/' + id, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({value: value})
}).then(() => location.reload());
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('canvas[id^="chart-"]').forEach(canvas => {
const labels = JSON.parse(canvas.dataset.labels);
const values = JSON.parse(canvas.dataset.values);
const color = values[values.length-1] >= values[0] ? '#5A9E68' : '#c07070';
new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: values,
borderColor: color,
backgroundColor: color + '22',
borderWidth: 2,
pointRadius: 3,
pointBackgroundColor: color,
fill: true,
tension: 0.3,
}]
},
options: {
plugins: { legend: { display: false }, tooltip: {
callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(2) }
}},
scales: {
x: { display: false },
y: { display: false }
},
animation: false,
}
});
});
});
function openEditFlip(id, year, make, model, trim, color, price, purchaseDate, notes) {
document.getElementById('edit-flip-title').textContent = 'Edit — ' + year + ' ' + make + ' ' + model;
document.getElementById('edit-flip-form').action = '/assets/flip/edit/' + id;
document.getElementById('ef-year').value = year;
document.getElementById('ef-make').value = make;
document.getElementById('ef-model').value = model;
document.getElementById('ef-trim').value = trim;
document.getElementById('ef-color').value = color;
document.getElementById('ef-price').value = price != '0' ? price : '';
document.getElementById('ef-date').value = purchaseDate;
document.getElementById('ef-notes').value = notes;
document.getElementById('edit-flip-modal').style.display = 'flex';
}
function closeEditFlip() {
document.getElementById('edit-flip-modal').style.display = 'none';
}
document.getElementById('edit-flip-modal').addEventListener('click', function(e) {
if (e.target === this) closeEditFlip();
});
function filterAssets(q) {
q = q.toLowerCase();
document.querySelectorAll('.sinking-card, .debt-card, .asset-card').forEach(card => {
card.style.display = card.textContent.toLowerCase().includes(q) ? '' : 'none';
});
// Also filter table rows in the flips section
document.querySelectorAll('tbody tr').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
</script>
{% endblock %}

79
templates/base.html Normal file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🌙 Moon Household Budget</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav>
<div class="nav-brand">🌙 Moon Household Budget</div>
<div class="nav-links">
<a href="{{ url_for('weekly_overview') }}">Weekly</a>
<a href="{{ url_for('index') }}">Overview</a>
<a href="{{ url_for('bill_calendar') }}">Calendar</a>
<a href="{{ url_for('transactions_view') }}">Transactions</a>
<a href="{{ url_for('income_view') }}">Income</a>
<a href="{{ url_for('taxes_view') }}">Taxes</a>
<a href="{{ url_for('bills_list') }}">Bills</a>
<a href="{{ url_for('sinking_view') }}">Savings</a>
<a href="{{ url_for('assets_view') }}">Assets</a>
<a href="{{ url_for('mortgage_view') }}">Mortgage</a>
<a href="{{ url_for('debts_view') }}">Debt</a>
<a href="{{ url_for('net_worth_view') }}">Net Worth</a>
<a href="{{ url_for('summary') }}">Spending Report</a>
<a href="{{ url_for('suggestions.suggestions_page') }}" class="nav-suggest" title="Suggestions &amp; feedback" aria-label="Suggestions">💡</a>
<a href="{{ url_for('settings_view') }}" class="nav-settings" title="Settings" aria-label="Settings">⚙️</a>
</div>
</nav>
<main>
{% if unreviewed_count > 0 %}
<div class="alert-banner" style="margin-bottom:1rem;">
<span>You have <strong>{{ unreviewed_count }}</strong> transaction{{ 's' if unreviewed_count != 1 }} waiting to be categorized.</span>
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Now</a>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer style="text-align:center;padding:1.5rem 0 2.25rem;">
<a href="{{ url_for('changelog_view') }}" title="What's new"
style="display:inline-block;font-size:.75rem;color:#B0A89E;text-decoration:none;border:1px solid #EDE8E2;border-radius:999px;padding:.2rem .65rem;">
🌙 Moon Household Budget · v{{ app_version }}
</a>
</footer>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Wrap all number budget-inputs with a $ prefix and format on blur
document.querySelectorAll('input[type="number"].budget-input').forEach(function (input) {
// Skip if already wrapped or explicitly opted out
if (input.classList.contains('no-dollar') || input.parentElement.classList.contains('dollar-wrap')) return;
// Wrap in dollar-sign container
const wrap = document.createElement('span');
wrap.className = 'dollar-wrap';
wrap.style.width = input.style.width || '100%';
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
// Format existing value on load
if (input.value !== '' && !isNaN(parseFloat(input.value))) {
input.value = parseFloat(input.value).toFixed(2);
}
// Format to 2 decimal places on blur
input.addEventListener('blur', function () {
if (this.value !== '' && !isNaN(parseFloat(this.value))) {
this.value = parseFloat(this.value).toFixed(2);
}
});
// On focus, select all for easy overtyping
input.addEventListener('focus', function () {
this.select();
});
});
});
</script>
</body>
</html>

228
templates/bills.html Normal file
View File

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Manage Bills</h1>
<a href="{{ url_for('bill_calendar') }}" class="btn btn-ghost">View Calendar</a>
</div>
<div class="card-wide">
<h2>Add a Bill</h2>
<form method="POST" action="{{ url_for('bill_add') }}" class="bill-form">
<div class="bill-form-grid">
<div class="form-group">
<label>Bill Name</label>
<input type="text" name="name" placeholder="e.g. Verizon, Affirm (home)" required>
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Due Day of Month</label>
<input type="number" name="due_day" min="1" max="31" placeholder="131" required>
</div>
<div class="form-group">
<label>Category</label>
<select name="category" id="add-category" onchange="updateAddSubcats()">
<option value="">— select —</option>
{% for cat in categories %}
<option value="{{ cat }}">{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Subcategory <span class="optional">(optional)</span></label>
<select name="subcategory" id="add-subcategory">
<option value="">— none —</option>
</select>
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="e.g. T=Theresa, B=both">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-top:0.5rem;">
<input type="checkbox" name="autopay" id="autopay-new">
<label for="autopay-new" style="margin:0;font-weight:400;">On autopay (no reminder needed)</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Add Bill</button>
</form>
</div>
{% if bills %}
<section class="section">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem;margin-bottom:0.75rem;">
<h2>Your Bills <span class="muted">({{ bills | length }} total)</span></h2>
<input type="text" id="bills-search" placeholder="Search bills…" class="budget-input" style="width:220px;" oninput="filterBills(this.value)">
</div>
<table class="txn-table txn-table-full">
<thead>
<tr>
<th>Due Day</th>
<th>Name</th>
<th>Amount</th>
<th>Category</th>
<th>Autopay</th>
<th>Remind Me</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for b in bills | sort(attribute='due_day') %}
<tr id="row-{{ b.id }}">
<td class="amount">{{ b.due_day }}</td>
<td>{{ b.name }}</td>
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted">varies</span>{% endif %}</td>
<td>
{% if b.category %}
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">
{{ b.category }}{% if b.subcategory %} {{ b.subcategory }}{% endif %}
</span>
{% endif %}
</td>
<td>
<button class="toggle-btn {% if b.get('autopay') %}toggle-on{% endif %}"
onclick="toggle('{{ b.id }}', 'autopay', this)">
{{ '✓ Yes' if b.get('autopay') else 'No' }}
</button>
</td>
<td>
<button class="toggle-btn {% if b.get('remind', not b.get('autopay')) %}toggle-on{% endif %}"
onclick="toggle('{{ b.id }}', 'remind', this)">
{{ '🔔 Yes' if b.get('remind', not b.get('autopay')) else 'Off' }}
</button>
</td>
<td class="muted">{{ b.notes }}</td>
<td style="white-space:nowrap;">
<button class="edit-link" onclick="openEdit('{{ b.id }}')" style="background:none;border:none;cursor:pointer;color:#8B7BAB;margin-right:0.5rem;">Edit</button>
<form method="POST" action="{{ url_for('bill_delete', bill_id=b.id) }}"
onsubmit="return confirm('Delete {{ b.name }}?');" style="display:inline;">
<button type="submit" class="edit-link" style="background:none;border:none;cursor:pointer;color:#C5A0A0;">Delete</button>
</form>
</td>
</tr>
<tr id="edit-{{ b.id }}" style="display:none;background:var(--bg-card,#faf8f5);">
<td colspan="8" style="padding:1rem;">
<form method="POST" action="{{ url_for('bill_edit', bill_id=b.id) }}" class="bill-form">
<div class="bill-form-grid">
<div class="form-group">
<label>Bill Name</label>
<input type="text" name="name" value="{{ b.name }}" required>
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" name="amount" step="0.01" min="0" value="{{ b.amount or '' }}" placeholder="0.00">
</div>
<div class="form-group">
<label>Due Day of Month</label>
<input type="number" name="due_day" min="1" max="31" value="{{ b.due_day }}" required>
</div>
<div class="form-group">
<label>Category</label>
<select name="category" id="edit-cat-{{ b.id }}" onchange="updateEditSubcats('{{ b.id }}')">
<option value="">— select —</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if b.category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Subcategory <span class="optional">(optional)</span></label>
<select name="subcategory" id="edit-subcat-{{ b.id }}">
<option value="">— none —</option>
{% for s in categories.get(b.category, []) %}
<option value="{{ s }}" {% if b.subcategory == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" value="{{ b.notes or '' }}">
</div>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost" onclick="closeEdit('{{ b.id }}')">Cancel</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% else %}
<div class="empty-state">
<p>No bills yet. Add your first one above.</p>
</div>
{% endif %}
<div class="reminder-bar">
<span>Run reminders now to test your email & Mac notifications:</span>
<button class="btn btn-ghost" onclick="runReminders(this)">Send Test Reminder</button>
<span class="reminder-result" id="reminder-result"></span>
</div>
<script>
const subcats = {{ categories | tojson }};
function updateAddSubcats() {
const cat = document.getElementById('add-category').value;
const sel = document.getElementById('add-subcategory');
sel.innerHTML = '<option value="">— none —</option>';
(subcats[cat] || []).forEach(s => {
const o = document.createElement('option');
o.value = s; o.textContent = s;
sel.appendChild(o);
});
}
function toggle(billId, field, btn) {
fetch(`/bills/toggle/${billId}/${field}`, { method: 'POST' })
.then(r => r.json())
.then(() => location.reload());
}
function openEdit(id) {
document.querySelectorAll('[id^="edit-"]').forEach(r => r.style.display = 'none');
document.getElementById('edit-' + id).style.display = '';
document.getElementById('row-' + id).classList.add('editing');
}
function closeEdit(id) {
document.getElementById('edit-' + id).style.display = 'none';
document.getElementById('row-' + id).classList.remove('editing');
}
function updateEditSubcats(id) {
const cat = document.getElementById('edit-cat-' + id).value;
const sel = document.getElementById('edit-subcat-' + id);
sel.innerHTML = '<option value="">— none —</option>';
(subcats[cat] || []).forEach(s => {
const o = document.createElement('option');
o.value = s; o.textContent = s;
sel.appendChild(o);
});
}
function filterBills(q) {
q = q.toLowerCase();
document.querySelectorAll('.txn-table tbody tr').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
function runReminders(btn) {
btn.textContent = 'Sending…';
btn.disabled = true;
fetch('/reminders/run', { method: 'POST' })
.then(r => r.json())
.then(data => {
document.getElementById('reminder-result').textContent = data.message;
btn.textContent = 'Send Test Reminder';
btn.disabled = false;
});
}
</script>
{% endblock %}

50
templates/calendar.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>{{ month_name }} {{ year }} — Bill Calendar</h1>
<div class="month-nav">
<a href="{{ url_for('bill_calendar', year=prev_year, month=prev_month) }}" class="btn btn-ghost"> Prev</a>
<a href="{{ url_for('bill_calendar', year=next_year, month=next_month) }}" class="btn btn-ghost">Next </a>
</div>
</div>
<div class="cal-wrap">
<div class="cal-grid">
{% for day_name in ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'] %}
<div class="cal-header">{{ day_name }}</div>
{% endfor %}
{% for week in grid %}
{% for day in week %}
{% if day is none %}
<div class="cal-cell cal-empty"></div>
{% else %}
<div class="cal-cell {% if today.day == day and today.month == month and today.year == year %}cal-today{% endif %}">
<div class="cal-day-num">{{ day }}</div>
{% for bill in bills_by_day.get(day, []) %}
<div class="cal-bill" style="background: {{ colors.get(bill.category, '#ddd') }};{% if bill.get('paid') %}opacity:0.45;{% endif %}">
<span class="cal-bill-name" style="{% if bill.get('paid') %}text-decoration:line-through;{% endif %}">{{ bill.name }}</span>
{% if bill.amount %}<span class="cal-bill-amt">${{ bill.amount | money }}</span>{% endif %}
{% if bill.get('paid') %}<span style="font-size:0.65rem;"></span>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</div>
<div class="cal-legend">
{% for cat, color in colors.items() %}
<span class="legend-item">
<span class="legend-dot" style="background: {{ color }};"></span>{{ cat }}
</span>
{% endfor %}
</div>
<div class="cal-actions">
<a href="{{ url_for('bills_list') }}" class="btn btn-primary">Manage Bills</a>
<a href="{{ url_for('weekly_overview', year=year, month=month) }}" class="btn btn-ghost">Weekly Overview →</a>
</div>
{% endblock %}

19
templates/changelog.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div style="max-width:720px;">
<h1>📒 What's New</h1>
<p style="color:#7A6E68;margin-bottom:1.25rem;">Current version: <strong>v{{ app_version }}</strong></p>
{% for rel in changelog %}
<div style="border:1px solid #EDE8E2;border-radius:10px;padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="display:flex;align-items:baseline;gap:.6rem;flex-wrap:wrap;">
<h2 style="margin:0;font-size:1.1rem;">v{{ rel.version }}</h2>
<span style="color:#B0A89E;font-size:.85rem;">{{ rel.date }}{% if rel.title %} · {{ rel.title }}{% endif %}</span>
</div>
<ul style="margin:.6rem 0 0;padding-left:1.2rem;">
{% for c in rel.changes %}<li style="margin:.3rem 0;">{{ c }}</li>{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endblock %}

774
templates/debts.html Normal file
View File

@@ -0,0 +1,774 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Debt Tracker — {{ month_name }} {{ year }}</h1>
<div class="month-nav">
<a href="{{ url_for('debts_view', year=prev_year, month=prev_month) }}" class="btn btn-ghost"> Prev</a>
<a href="{{ url_for('debts_view', year=next_year, month=next_month) }}" class="btn btn-ghost">Next </a>
<a href="{{ url_for('debt_strategy') }}" class="btn" style="background:#C47A4A;color:#fff;border:none;">📊 Debt Payoff Strategy</a>
</div>
</div>
<!-- Add Forms -->
<div style="display:flex;gap:0.75rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<details class="add-debt-details">
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Add Credit Card</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<form method="POST" action="{{ url_for('debt_add') }}" class="bill-form">
<input type="hidden" name="debt_category" value="Credit Cards">
<div class="bill-form-grid">
<div class="form-group">
<label>Card Name</label>
<input type="text" name="name" placeholder="e.g. Discover, Chase, Indigo" required>
</div>
<div class="form-group">
<label>Owner</label>
<select name="owner">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group">
<label>Current Balance</label>
<input type="number" name="current_balance" step="0.01" min="0" placeholder="0.00" required>
</div>
<div class="form-group">
<label>APR % <span class="optional">(leave blank if unknown)</span></label>
<input type="number" name="apr" step="0.01" min="0" max="100" placeholder="e.g. 24.99">
</div>
<div class="form-group">
<label>Minimum Payment</label>
<input type="number" name="minimum" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Account Name in App <span class="optional">(to match transactions)</span></label>
<input type="text" name="account_match" placeholder="e.g. Discover">
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="e.g. balance transfer, 0% promo ends Aug">
</div>
</div>
<button type="submit" class="btn btn-primary">Add Debt</button>
</form>
</div>
</details>
<details class="add-debt-details">
<summary class="btn btn-dusty-rose" style="display:inline-block;cursor:pointer;">+ Add Medical Debt</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<form method="POST" action="{{ url_for('debt_add') }}" class="bill-form">
<input type="hidden" name="debt_category" value="Medical Payment Plans">
<div class="bill-form-grid">
<div class="form-group">
<label>Provider / Institution</label>
<input type="text" name="name" placeholder="e.g. Marshfield Clinic, Oakleaf" required>
</div>
<div class="form-group">
<label>Owner</label>
<select name="owner">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group">
<label>Current Balance</label>
<input type="number" name="current_balance" step="0.01" min="0" placeholder="0.00" required>
</div>
<div class="form-group">
<label>APR % <span class="optional">(0% if interest-free plan)</span></label>
<input type="number" name="apr" step="0.01" min="0" max="100" placeholder="0.00">
</div>
<div class="form-group">
<label>Monthly Payment</label>
<input type="number" name="minimum" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Account Name in App <span class="optional">(optional)</span></label>
<input type="text" name="account_match" placeholder="e.g. Marshfield">
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="e.g. interest-free, started Jan 2026">
</div>
</div>
<button type="submit" class="btn btn-primary">Add Medical Debt</button>
</form>
</div>
</details>
<details class="add-debt-details">
<summary class="btn btn-powder-blue" style="display:inline-block;cursor:pointer;">+ Add Affirm / Klarna / BNPL Plan</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<form method="POST" action="{{ url_for('bnpl_add') }}" class="bill-form">
<div class="bill-form-grid">
<div class="form-group">
<label>Provider</label>
<select name="bnpl_provider">
<option>Affirm</option>
<option>Klarna</option>
<option>Afterpay</option>
<option>Zip</option>
<option>Other</option>
</select>
</div>
<div class="form-group">
<label>What was it for?</label>
<input type="text" name="description" placeholder="e.g. Living room couch" required>
</div>
<div class="form-group">
<label>Merchant / Store</label>
<input type="text" name="merchant" placeholder="e.g. Wayfair, Amazon">
</div>
<div class="form-group">
<label>Owner</label>
<select name="owner">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group">
<label>Original Purchase Amount</label>
<input type="number" name="original_amount" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Payment Amount</label>
<input type="number" name="payment_amount" step="0.01" min="0" placeholder="0.00" required>
</div>
<div class="form-group">
<label>Total Number of Payments</label>
<input type="number" name="total_payments" min="1" placeholder="e.g. 4 or 6 or 12" required>
</div>
<div class="form-group">
<label>Payments Already Made</label>
<input type="number" name="payments_made" min="0" value="0">
</div>
<div class="form-group">
<label>Payment Frequency</label>
<select name="frequency">
<option value="biweekly">Every 2 weeks</option>
<option value="monthly">Monthly</option>
<option value="4-payment">4 payments (every 2 weeks)</option>
</select>
</div>
<div class="form-group">
<label>Next Payment Date</label>
<input type="date" name="next_payment_date" required>
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="e.g. for bedroom, holiday gift">
</div>
</div>
<button type="submit" class="btn btn-primary">Add BNPL Plan</button>
</form>
</div>
</details>
<details class="add-debt-details">
<summary class="btn btn-moss" style="display:inline-block;cursor:pointer;">📋 Add Card by Pasting Statement</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<p class="muted" style="font-size:0.85rem;margin-bottom:1rem;">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.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div class="form-group">
<label>Card Name</label>
<input type="text" id="paste-card-name" placeholder="e.g. Self Visa, Discover" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label>Owner</label>
<select id="paste-card-owner" class="budget-input" style="width:100%;">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
</div>
<div class="form-group">
<label>Paste Statement Text</label>
<textarea id="paste-new-statement" style="width:100%;height:160px;border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.82rem;resize:vertical;" placeholder="Copy all the text from your statement and paste it here…"></textarea>
</div>
<div id="paste-new-result" style="font-size:0.85rem;margin-bottom:0.75rem;"></div>
<button class="btn btn-primary" onclick="addCardFromStatement()">Add Card & Import Statement</button>
</div>
</details>
</div>
<!-- Debt Summary Breakdown -->
<div class="debt-summary-block">
<table class="debt-summary-table">
<thead>
<tr>
<th>Category</th>
<th>Balance</th>
<th>Minimums</th>
<th>Paid This Month</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
{% for cat in debt_categories %}
{% set ct = totals[cat] %}
{% if by_category[cat] %}
<tr>
<td>{{ cat }}</td>
<td class="amount">${{ ct.balance | money }}</td>
<td class="amount">${{ ct.minimum | money }}</td>
<td class="amount">${{ ct.paid | money }}</td>
<td class="amount accent">${{ ct.extra | money }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td><strong>Grand Total</strong></td>
<td class="amount"><strong>${{ grand.balance | money }}</strong></td>
<td class="amount"><strong>${{ grand.minimum | money }}</strong></td>
<td class="amount"><strong>${{ grand.paid | money }}</strong></td>
<td class="amount accent"><strong>${{ grand.extra | money }}</strong></td>
</tr>
</tfoot>
</table>
</div>
<!-- Debt-to-Income Ratio -->
{% 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 = "2036% is average. Keep an eye on it." %}
{% elif dti <= 50 %}
{% set dti_color = "#E8956A" %}{% set dti_label = "High" %}{% set dti_tip = "3650% 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 %}
<div style="display:flex;align-items:center;gap:1.5rem;background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;flex-wrap:wrap;">
<div>
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);margin-bottom:0.2rem;">Debt-to-Income Ratio</div>
<div style="font-size:1.8rem;font-weight:700;color:{{ dti_color }};">{{ dti }}%</div>
</div>
<div style="flex:1;min-width:180px;">
<div style="display:flex;justify-content:space-between;font-size:0.78rem;margin-bottom:0.3rem;">
<span style="color:{{ dti_color }};font-weight:600;">{{ dti_label }}</span>
<span class="muted">${{ "%.0f" | format(grand.minimum) }} min / ${{ "%.0f" | format(monthly_income) }} income</span>
</div>
<div style="background:var(--border);border-radius:99px;height:8px;overflow:hidden;">
<div style="height:100%;width:{{ [dti, 100] | min }}%;background:{{ dti_color }};border-radius:99px;transition:width 0.3s;"></div>
</div>
<div style="font-size:0.75rem;color:var(--text-light);margin-top:0.3rem;">{{ dti_tip }}</div>
</div>
</div>
{% else %}
<div style="background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1.25rem;margin-bottom:1rem;font-size:0.85rem;color:var(--text-light);">
DTI ratio unavailable — <a href="{{ url_for('income_view') }}">log this month's income</a> to see your debt-to-income ratio.
</div>
{% endif %}
<!-- Search -->
<div style="margin-bottom:1rem;">
<input type="text" id="debt-search" placeholder="Search debts…" class="budget-input" style="width:240px;" oninput="filterDebts(this.value)">
</div>
<!-- By Category -->
{% for cat in debt_categories %}
{% set debts = by_category[cat] %}
{% set cat_totals = totals[cat] %}
{% if debts %}
<section class="section">
<div class="debt-cat-header" onclick="toggleDebtSection('debt-section-{{ loop.index }}')" style="cursor:pointer;user-select:none;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="debt-collapse-arrow" id="arrow-debt-section-{{ loop.index }}"></span>
<h2 style="margin:0;">{{ cat }}</h2>
</div>
<div class="debt-cat-totals">
<span>Balance: <strong>${{ cat_totals.balance | money }}</strong></span>
<span>Minimums: <strong>${{ cat_totals.minimum | money }}</strong></span>
<span>Paid: <strong>${{ cat_totals.paid | money }}</strong></span>
<span class="accent">Extra: <strong>${{ cat_totals.extra | money }}</strong></span>
</div>
</div>
<div class="debt-cards" id="debt-section-{{ loop.index }}">
{% for d in debts %}
<div class="debt-card">
<div class="debt-card-top">
<div>
<div class="debt-card-name">{{ d.name }}</div>
{% if d.notes %}<div class="debt-card-notes">{{ d.notes }}</div>{% endif %}
</div>
<div class="debt-card-badges">
<span class="owner-tag" style="background: {{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span>
{% if d.apr %}<span class="apr-tag">{{ d.apr }}% APR</span>{% endif %}
{% if d.get('is_bnpl') %}
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
onclick="openEditBNPL('{{ d.id }}','{{ d.name }}','{{ d.merchant or '' }}','{{ d.bnpl_provider or 'Affirm' }}','{{ d.owner }}','{{ d.original_amount or 0 }}','{{ d.payment_amount or 0 }}','{{ d.total_payments or 0 }}','{{ d.payments_made or 0 }}','{{ d.frequency or 'biweekly' }}','{{ d.next_payment_date or '' }}','{{ d.notes or '' }}')">
✏️ Edit
</button>
{% else %}
<button class="edit-link" style="background:none;border:none;cursor:pointer;font-size:0.78rem;"
onclick="openEditDebt('{{ d.id }}','{{ d.name }}','{{ d.owner }}','{{ d.apr or 0 }}','{{ d.minimum or 0 }}','{{ d.current_balance or 0 }}','{{ d.credit_limit or 0 }}','{{ d.notes or '' }}')">
✏️ Edit
</button>
{% endif %}
</div>
</div>
<div class="debt-card-balance">
<span class="debt-balance-label">Balance</span>
<span class="debt-balance-value" id="balance-{{ d.id }}">${{ d.ending | money }}</span>
</div>
{% if d.credit_limit %}
{% set available = d.credit_limit - d.ending %}
{% set util_pct = [(d.ending / d.credit_limit * 100), 100] | min | int %}
<div style="margin-bottom:0.75rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.3rem;">
<span style="color:var(--text-light);">Available credit</span>
<span style="font-weight:600;color:{% if available < 0 %}#c07070{% elif util_pct > 80 %}#c07070{% elif util_pct > 50 %}#b8943f{% else %}#5A9E68{% endif %};">
${{ available | money }} of ${{ d.credit_limit | money }}
</span>
</div>
<div style="background:#EDE8E2;border-radius:4px;height:6px;">
<div style="background:{% if util_pct > 80 %}#c07070{% elif util_pct > 50 %}#d4a843{% else %}#5A9E68{% endif %};border-radius:4px;height:6px;width:{{ util_pct }}%;"></div>
</div>
<div style="font-size:0.73rem;color:var(--text-light);margin-top:0.2rem;">{{ util_pct }}% utilized</div>
</div>
{% endif %}
{% if d.payoff_str and not d.get('is_bnpl') %}
<div style="margin-bottom:0.75rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.3rem;">
<span style="color:var(--text-light);">Payoff projection</span>
<span style="font-weight:600;color:#5A9E68;">{{ d.payoff_str }}</span>
</div>
{% if d.months_left and d.months_left <= 36 %}
{% set payoff_pct = [100 - (d.months_left / 36 * 100), 5] | max | int %}
<div style="background:#EDE8E2;border-radius:4px;height:6px;">
<div style="background:#5A9E68;border-radius:4px;height:6px;width:{{ payoff_pct }}%;"></div>
</div>
{% endif %}
<div style="font-size:0.73rem;color:var(--text-light);margin-top:0.2rem;">
{{ d.months_left }} month{{ 's' if d.months_left != 1 }} at minimum payments
{% if d.apr %} · {{ d.apr }}% APR{% endif %}
</div>
</div>
{% endif %}
<!-- Statement pending toggle -->
{% if not d.get('is_bnpl') %}
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
<button class="toggle-btn {% if d.get('statement_pending') %}toggle-on{% endif %}"
onclick="togglePending('{{ d.id }}', this)"
style="font-size:0.75rem;padding:0.2rem 0.6rem;">
{{ '⏳ Statement Pending' if d.get('statement_pending') else 'Statement closed' }}
</button>
{% if d.get('statement_pending') %}
<span style="font-size:0.75rem;color:var(--text-light);">numbers may not reflect current statement</span>
{% endif %}
</div>
{% 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) %}
<div class="bnpl-progress-wrap">
<div class="bnpl-progress-bar">
<div class="bnpl-progress-fill" style="width: {{ pct }}%;"></div>
</div>
<div class="bnpl-progress-label">{{ d.payments_made }} of {{ d.total_payments }} payments made</div>
</div>
<table class="debt-month-table">
<tr><td>Payment Amount</td><td class="amount">${{ d.payment_amount | money }}</td></tr>
<tr><td>Payments Remaining</td><td class="amount">{{ remaining_payments }}</td></tr>
<tr><td>Still Owed</td><td class="amount">${{ d.current_balance | money }}</td></tr>
{% if payoff_str %}<tr><td>Payoff Date</td><td class="amount accent">{{ payoff_str }}</td></tr>{% endif %}
</table>
<form method="POST" action="{{ url_for('bnpl_payment', debt_id=d.id) }}" style="margin-bottom:0.5rem;">
<button type="submit" class="btn btn-accept" onclick="return confirm('Mark one payment as made?')">
✓ Mark Payment Made
</button>
</form>
{% else %}
<table class="debt-month-table">
<tr>
<td>Starting Balance</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ d.starting }}"
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'starting_balance', this.value, '{{ d.id }}')"
placeholder="0.00"></td>
</tr>
<tr>
<td>Interest Charged</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ d.interest }}"
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'interest', this.value, '{{ d.id }}')"
placeholder="auto-calculated"></td>
</tr>
<tr>
<td>New Charges</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ d.new_charges or '' }}"
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'new_charges', this.value, '{{ d.id }}')"
placeholder="0.00"></td>
</tr>
<tr>
<td>Minimum Due</td>
<td class="amount">${{ d.minimum | money }}</td>
</tr>
<tr>
<td>Payment Made</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ d.payment or '' }}"
onchange="logDebt('{{ d.id }}', {{ year }}, {{ month }}, 'payment', this.value, '{{ d.id }}')"
placeholder="0.00"></td>
</tr>
<tr class="debt-extra-row">
{% set underpaid = d.payment > 0 and d.payment < d.minimum and not d.get('statement_pending') %}
<td>{% if underpaid %}⚠️ Underpaid Minimum{% else %}Extra Paid Above Minimum{% endif %}</td>
<td class="amount {% if underpaid %}{% else %}accent{% endif %}" id="extra-{{ d.id }}"
{% if underpaid %}style="color:#c07070;"{% endif %}>
{% if underpaid %}
-${{ d.minimum - d.payment | money }} short
{% elif d.get('statement_pending') %}
<span class="muted"></span>
{% else %}
${{ d.extra | money }}
{% endif %}
</td>
</tr>
</table>
{% endif %}
{% if not d.get('is_bnpl') %}
<button class="btn btn-ghost" style="font-size:0.78rem;margin-bottom:0.5rem;width:100%;"
onclick="openStatementModal('{{ d.id }}', '{{ d.name }}')">📋 Paste Statement</button>
{% endif %}
<form method="POST" action="{{ url_for('debt_delete', debt_id=d.id) }}"
onsubmit="return confirm('Remove {{ d.name }} from debt tracker?');">
<button type="submit" class="debt-delete-btn">Remove</button>
</form>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% if not (by_category.values() | map('list') | sum(start=[])) %}
<div class="empty-state">
<p>No debts added yet. Click "+ Add a Debt" above to get started.</p>
</div>
{% endif %}
<script>
function toggleDebtSection(id) {
const el = document.getElementById(id);
const arrow = document.getElementById('arrow-' + id);
if (!el) return;
const collapsed = el.style.display === 'none';
el.style.display = collapsed ? '' : 'none';
if (arrow) arrow.textContent = collapsed ? '▾' : '▸';
}
function togglePending(debtId, btn) {
fetch(`/debts/toggle-pending/${debtId}`, {method: 'POST'})
.then(r => r.json())
.then(data => {
btn.textContent = data.pending ? '⏳ Statement Pending' : 'Statement closed';
btn.classList.toggle('toggle-on', data.pending);
location.reload();
});
}
function filterDebts(q) {
q = q.toLowerCase();
document.querySelectorAll('.debt-card').forEach(card => {
card.style.display = card.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
function logDebt(debtId, year, month, field, value, cardId) {
const body = new URLSearchParams({debt_id: debtId, year, month, field, value});
fetch('/debts/log', {method: 'POST', body})
.then(r => r.json())
.then(data => {
if (data.ending !== undefined) {
const bal = document.getElementById('balance-' + cardId);
if (bal) bal.textContent = '$' + data.ending.toFixed(2);
}
if (data.extra !== undefined) {
const ext = document.getElementById('extra-' + cardId);
if (ext) ext.textContent = '$' + data.extra.toFixed(2);
}
});
}
</script>
<!-- Edit Debt Modal -->
<div id="edit-debt-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
<h3 style="margin:0 0 1rem;" id="edit-debt-title">Edit Debt</h3>
<form method="POST" id="edit-debt-form" action="">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div class="form-group">
<label style="font-size:0.85rem;">Card / Debt Name</label>
<input type="text" name="name" id="edit-name" class="budget-input" style="width:100%;" required>
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Owner</label>
<select name="owner" id="edit-owner" class="budget-input" style="width:100%;">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Current Balance</label>
<input type="number" name="current_balance" id="edit-balance" step="0.01" min="0" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">APR %</label>
<input type="number" name="apr" id="edit-apr" step="0.01" min="0" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Minimum Payment</label>
<input type="number" name="minimum" id="edit-minimum" step="0.01" min="0" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Credit Limit</label>
<input type="number" name="credit_limit" id="edit-credit-limit" step="0.01" min="0" class="budget-input" style="width:100%;">
</div>
</div>
<div class="form-group" style="margin-top:0.5rem;">
<label style="font-size:0.85rem;">Notes</label>
<input type="text" name="notes" id="edit-notes" class="budget-input" style="width:100%;" placeholder="optional">
</div>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
<button type="button" class="btn btn-ghost" onclick="closeEditDebt()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Edit BNPL Modal -->
<div id="edit-bnpl-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:520px;box-shadow:0 8px 32px rgba(0,0,0,0.18);max-height:90vh;overflow-y:auto;">
<h3 style="margin:0 0 1rem;" id="edit-bnpl-title">Edit BNPL Plan</h3>
<form method="POST" id="edit-bnpl-form" action="">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div class="form-group">
<label style="font-size:0.85rem;">What is it for?</label>
<input type="text" name="description" id="ebnpl-description" class="budget-input" style="width:100%;" required>
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Merchant / Store</label>
<input type="text" name="merchant" id="ebnpl-merchant" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Provider</label>
<select name="bnpl_provider" id="ebnpl-provider" class="budget-input" style="width:100%;">
<option>Affirm</option>
<option>Klarna</option>
<option>Afterpay</option>
<option>Zip</option>
<option>Other</option>
</select>
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Owner</label>
<select name="owner" id="ebnpl-owner" class="budget-input" style="width:100%;">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Original Purchase Amount</label>
<input type="number" step="0.01" name="original_amount" id="ebnpl-original" class="budget-input" style="width:100%;" placeholder="0.00">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Payment Amount</label>
<input type="number" step="0.01" name="payment_amount" id="ebnpl-payment" class="budget-input" style="width:100%;" placeholder="0.00">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Total Payments</label>
<input type="number" min="1" name="total_payments" id="ebnpl-total" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Payments Already Made</label>
<input type="number" min="0" name="payments_made" id="ebnpl-made" class="budget-input" style="width:100%;">
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Frequency</label>
<select name="frequency" id="ebnpl-frequency" class="budget-input" style="width:100%;">
<option value="biweekly">Every 2 weeks</option>
<option value="monthly">Monthly</option>
<option value="4-payment">4 payments (every 2 weeks)</option>
</select>
</div>
<div class="form-group">
<label style="font-size:0.85rem;">Next Payment Date</label>
<input type="date" name="next_payment_date" id="ebnpl-next-date" class="budget-input" style="width:100%;">
</div>
</div>
<div class="form-group" style="margin-top:0.5rem;">
<label style="font-size:0.85rem;">Notes</label>
<input type="text" name="notes" id="ebnpl-notes" class="budget-input" style="width:100%;" placeholder="optional">
</div>
<p class="muted" style="font-size:0.78rem;margin-top:0.75rem;">Saving will delete any unpaid calendar entries for this plan and regenerate them based on the updated dates.</p>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1rem;">
<button type="button" class="btn btn-ghost" onclick="closeEditBNPL()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Paste Statement Modal -->
<div id="statement-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:1.5rem;width:90%;max-width:540px;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
<h3 style="margin:0 0 0.25rem;" id="statement-modal-title">Paste Statement</h3>
<p class="muted" style="font-size:0.82rem;margin-bottom:1rem;">Copy all the text from your statement and paste it below. The app will extract the numbers automatically.</p>
<textarea id="statement-text" style="width:100%;height:180px;border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.82rem;resize:vertical;" placeholder="Paste statement text here…"></textarea>
<div id="statement-result" style="margin:0.75rem 0;font-size:0.85rem;"></div>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:0.75rem;">
<button class="btn btn-ghost" onclick="closeStatementModal()">Cancel</button>
<button class="btn btn-primary" onclick="parseStatement()">Import</button>
</div>
</div>
</div>
<script>
let activeDebtId = null;
function addCardFromStatement() {
const name = document.getElementById('paste-card-name').value.trim();
const owner = document.getElementById('paste-card-owner').value;
const text = document.getElementById('paste-new-statement').value.trim();
const resultEl = document.getElementById('paste-new-result');
if (!name) { resultEl.style.color='#c07070'; resultEl.textContent='Please enter a card name.'; return; }
if (!text) { resultEl.style.color='#c07070'; resultEl.textContent='Please paste your statement text.'; return; }
resultEl.style.color='inherit';
resultEl.textContent='Creating card and importing…';
fetch('/debts/add-from-statement', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({name, owner, text, year: {{ year }}, month: {{ month }}})
})
.then(r => r.json())
.then(data => {
if (data.error) { resultEl.style.color='#c07070'; resultEl.textContent=data.error; return; }
resultEl.style.color='#5A9E68';
resultEl.textContent='✓ Card added and statement imported!';
setTimeout(() => location.reload(), 1200);
});
}
function openStatementModal(debtId, debtName) {
activeDebtId = debtId;
document.getElementById('statement-modal-title').textContent = 'Paste Statement — ' + debtName;
document.getElementById('statement-text').value = '';
document.getElementById('statement-result').textContent = '';
document.getElementById('statement-modal').style.display = 'flex';
}
function closeStatementModal() {
document.getElementById('statement-modal').style.display = 'none';
activeDebtId = null;
}
function parseStatement() {
const text = document.getElementById('statement-text').value;
if (!text.trim()) return;
const resultEl = document.getElementById('statement-result');
resultEl.textContent = 'Parsing…';
fetch('/debts/parse-statement', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({text, debt_id: activeDebtId, year: {{ year }}, month: {{ month }}})
})
.then(r => r.json())
.then(data => {
if (data.error) {
resultEl.style.color = '#c07070';
resultEl.textContent = 'Could not parse: ' + data.error;
return;
}
resultEl.style.color = '#5A9E68';
resultEl.innerHTML = `
✓ Imported:<br>
Previous balance: <strong>$${data.previous_balance}</strong> &nbsp;
Purchases: <strong>$${data.purchases}</strong> &nbsp;
Interest: <strong>$${data.interest}</strong> &nbsp;
Payment: <strong>$${data.payment}</strong> &nbsp;
Minimum due: <strong>$${data.minimum}</strong> &nbsp;
New balance: <strong>$${data.new_balance}</strong>
${data.credit_limit ? '&nbsp; Credit limit: <strong>$' + data.credit_limit + '</strong>' : ''}
${data.available_credit ? '&nbsp; Available: <strong>$' + data.available_credit + '</strong>' : ''}
`;
// Reload to show updated values
setTimeout(() => { closeStatementModal(); location.reload(); }, 1800);
});
}
document.getElementById('statement-modal').addEventListener('click', function(e) {
if (e.target === this) closeStatementModal();
});
function openEditDebt(id, name, owner, apr, minimum, balance, creditLimit, notes) {
document.getElementById('edit-debt-title').textContent = 'Edit — ' + name;
document.getElementById('edit-debt-form').action = '/debts/edit/' + id;
document.getElementById('edit-name').value = name;
document.getElementById('edit-owner').value = owner;
document.getElementById('edit-apr').value = apr != '0' ? apr : '';
document.getElementById('edit-minimum').value = minimum != '0' ? minimum : '';
document.getElementById('edit-balance').value = balance != '0' ? balance : '';
document.getElementById('edit-credit-limit').value = creditLimit != '0' ? creditLimit : '';
document.getElementById('edit-notes').value = notes;
document.getElementById('edit-debt-modal').style.display = 'flex';
}
function closeEditDebt() {
document.getElementById('edit-debt-modal').style.display = 'none';
}
document.getElementById('edit-debt-modal').addEventListener('click', function(e) {
if (e.target === this) closeEditDebt();
});
function openEditBNPL(id, description, merchant, provider, owner, original, payment, total, made, frequency, nextDate, notes) {
document.getElementById('edit-bnpl-title').textContent = 'Edit — ' + description;
document.getElementById('edit-bnpl-form').action = '/debts/bnpl-edit/' + id;
document.getElementById('ebnpl-description').value = description;
document.getElementById('ebnpl-merchant').value = merchant;
document.getElementById('ebnpl-provider').value = provider;
document.getElementById('ebnpl-owner').value = owner;
document.getElementById('ebnpl-original').value = original != '0' ? original : '';
document.getElementById('ebnpl-payment').value = payment != '0' ? payment : '';
document.getElementById('ebnpl-total').value = total;
document.getElementById('ebnpl-made').value = made;
document.getElementById('ebnpl-frequency').value = frequency;
document.getElementById('ebnpl-next-date').value = nextDate;
document.getElementById('ebnpl-notes').value = notes;
document.getElementById('edit-bnpl-modal').style.display = 'flex';
}
function closeEditBNPL() {
document.getElementById('edit-bnpl-modal').style.display = 'none';
}
document.getElementById('edit-bnpl-modal').addEventListener('click', function(e) {
if (e.target === this) closeEditBNPL();
});
</script>
{% endblock %}

234
templates/edit.html Normal file
View File

@@ -0,0 +1,234 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Edit Transaction</h1>
</div>
<div class="review-card">
<div class="review-meta">
<span class="review-date">{{ transaction.date }}</span>
<span class="review-account">{{ transaction.account }}</span>
</div>
<div class="review-description">{{ transaction.description }}</div>
<div class="review-amount">${{ transaction.amount | money }}</div>
<form method="POST" class="categorize-form">
<div class="form-group">
<label>Category</label>
<div class="category-grid">
{% for cat, subcats in categories.items() %}
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
<input type="radio" name="category" value="{{ cat }}"
{% if transaction.category == cat %}checked{% endif %}
onchange="updateSubcats(this.value)">
<span>{{ cat }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="form-group" id="subcat-group">
<label>Subcategory <span class="optional">(optional)</span></label>
<select name="subcategory" id="subcat-select">
<option value="">— none —</option>
</select>
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" value="{{ transaction.notes }}" placeholder="e.g. Costco run, birthday gift...">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
<a href="{{ url_for('transactions_view') }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
<!-- Split transaction panel -->
<div id="split-panel" style="display:none;margin-top:1.5rem;">
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
</div>
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
<form method="POST" action="{{ url_for('split_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="redirect" value="transactions">
<table class="budget-table">
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
<tbody id="split-rows">
{% if transaction.splits %}
{% for s in transaction.splits %}
<tr class="split-row">
<td>
<select name="split_cat_{{ loop.index0 }}" class="budget-input split-cat" onchange="updateSplitSubcat(this, {{ loop.index0 }})" style="width:130px;">
<option value="">— pick —</option>
{% for cat in categories %}<option value="{{ cat }}" {% if s.category == cat %}selected{% endif %}>{{ cat }}</option>{% endfor %}
</select>
</td>
<td><select name="split_subcat_{{ loop.index0 }}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_{{ loop.index0 }}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" value="{{ s.amount }}" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_{{ loop.index0 }}" class="budget-input" style="width:110px;" value="{{ s.note or '' }}"></td>
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;"></button></td>
</tr>
{% endfor %}
{% else %}
<tr class="split-row">
<td><select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this,0)" style="width:130px;"><option value="">— pick —</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select></td>
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td></td>
</tr>
<tr class="split-row">
<td><select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this,1)" style="width:130px;"><option value="">— pick —</option>{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}</select></td>
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td></td>
</tr>
{% endif %}
</tbody>
</table>
<div style="margin:0.75rem 0;font-size:0.85rem;">
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
</div>
<div style="display:flex;gap:0.75rem;">
<button type="submit" class="btn btn-primary">Save Split</button>
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
</div>
</form>
</div>
</div>
<div style="margin-top:1.25rem;padding-top:1.25rem;border-top:1px solid var(--border);display:flex;gap:0.5rem;flex-wrap:wrap;">
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="redirect" value="transactions">
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;">✓ Already tracked — ignore</button>
</form>
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
<div id="sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;margin-top:0.25rem;">
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="redirect" value="transactions">
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
<option value="">— select fund —</option>
{% for f in sinking_funds %}
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save</button>
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
</div>
</form>
</div>
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="label" value="Investment Transfer">
<input type="hidden" name="redirect" value="transactions">
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
</form>
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="label" value="Savings Transfer">
<input type="hidden" name="redirect" value="transactions">
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
</form>
</div>
</div>
<script>
const subcats = {{ categories | tojson }};
const current = {{ transaction | tojson }};
function updateSubcats(cat) {
const select = document.getElementById('subcat-select');
const group = document.getElementById('subcat-group');
const list = subcats[cat] || [];
select.innerHTML = '<option value="">— none —</option>';
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
if (current.subcategory === s) opt.selected = true;
select.appendChild(opt);
});
group.style.display = list.length ? 'block' : 'none';
}
const checked = document.querySelector('input[name="category"]:checked');
if (checked) updateSubcats(checked.value);
document.querySelectorAll('input[name="category"]').forEach(r => {
r.addEventListener('change', () => updateSubcats(r.value));
});
// Split transaction
const totalAmount = {{ transaction.amount }};
const allSubcats = {{ categories | tojson }};
let splitRowCount = {{ transaction.splits | length if transaction.splits else 2 }};
function toggleSplit() {
const panel = document.getElementById('split-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
if (panel.style.display === 'block') updateRemaining();
}
function updateSplitSubcat(selectEl, idx) {
const cat = selectEl.value;
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
const list = allSubcats[cat] || [];
const current = subcatSel ? subcatSel.dataset.current || '' : '';
subcatSel.innerHTML = '<option value="">— none —</option>';
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s; opt.textContent = s;
if (s === current) opt.selected = true;
subcatSel.appendChild(opt);
});
}
function updateRemaining() {
const inputs = document.querySelectorAll('.split-amt');
let used = 0;
inputs.forEach(el => used += parseFloat(el.value) || 0);
const remaining = Math.round((totalAmount - used) * 100) / 100;
const el = document.getElementById('split-remaining');
el.textContent = '$' + remaining.toFixed(2);
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
}
function addSplitRow() {
const idx = splitRowCount++;
const tbody = document.getElementById('split-rows');
const row = document.createElement('tr');
row.className = 'split-row';
row.innerHTML = `
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
<option value="">— pick —</option>
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
</select></td>
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
`;
tbody.appendChild(row);
}
// Pre-populate subcats for existing splits
document.querySelectorAll('.split-cat').forEach((sel, idx) => {
if (sel.value) updateSplitSubcat(sel, idx);
});
// Open split panel automatically if transaction already has splits
{% if transaction.splits %}
toggleSplit();
{% endif %}
</script>
{% endblock %}

65
templates/enroll.html Normal file
View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Add a device · Moon Household Budget</title>
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#FAF7F2;
display:flex; min-height:100vh; align-items:center; justify-content:center; margin:0; color:#3A3330; }
.card { background:#fff; border:1px solid #EDE8E2; border-radius:16px; padding:2rem; max-width:380px;
width:90%; text-align:center; box-shadow:0 4px 16px rgba(80,60,50,0.08); }
h1 { font-size:1.25rem; margin:0 0 .25rem; }
p.sub { color:#7A6E68; margin:0 0 1.25rem; font-size:.95rem; }
input { width:100%; box-sizing:border-box; padding:.7rem; border:1px solid #cfcfd4; border-radius:10px;
font:inherit; margin-bottom:.6rem; }
button { background:#4338ca; color:#fff; border:0; border-radius:10px; padding:.8rem 1.2rem;
font:inherit; font-weight:600; cursor:pointer; width:100%; }
button:disabled { opacity:.5; }
.msg { min-height:1.2rem; margin-top:.9rem; font-size:.9rem; }
.err { color:#C57A6E; } .ok { color:#5A9E68; }
a { color:#4338ca; font-size:.85rem; }
</style>
</head>
<body>
<div class="card">
<h1>🌙 Add this device</h1>
<p class="sub">Create a passkey (Touch ID / Face ID / security key) for this device.</p>
<input id="label" placeholder="Device name — e.g. Tony's iPhone" autocomplete="off">
{% if needs_code %}<input id="code" type="password" placeholder="Enrollment code" autocomplete="off">{% endif %}
<button id="enroll">🔑 Create passkey</button>
<div class="msg" id="msg"></div>
<p style="margin-top:1.25rem;"><a href="/login">Back to sign in</a></p>
</div>
<script>
const { startRegistration } = SimpleWebAuthnBrowser;
const msg = document.getElementById('msg');
const btn = document.getElementById('enroll');
btn.onclick = async () => {
btn.disabled = true; msg.className = 'msg'; msg.textContent = 'Follow your device prompt…';
const label = document.getElementById('label').value || 'Device';
const codeEl = document.getElementById('code');
const code = codeEl ? codeEl.value : undefined;
try {
const begin = await fetch('/auth/register/begin', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, code }),
});
if (!begin.ok) { msg.className = 'msg err'; msg.textContent = 'Not allowed to enroll (check the code).'; btn.disabled = false; return; }
const opts = await begin.json();
const attestation = await startRegistration(opts);
const res = await fetch('/auth/register/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestation),
});
const data = await res.json();
if (data.ok) { msg.className = 'msg ok'; msg.textContent = '✓ Passkey created! Redirecting…'; setTimeout(() => window.location = '/', 900); }
else { msg.className = 'msg err'; msg.textContent = 'Failed: ' + (data.error || 'try again'); btn.disabled = false; }
} catch (e) {
msg.className = 'msg err'; msg.textContent = 'Failed: ' + e.message; btn.disabled = false;
}
};
</script>
</body>
</html>

50
templates/import.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Import Statements</h1>
</div>
{% if message %}
<div class="alert-banner {{ 'alert-success' if 'Imported' in message else 'alert-error' }}">
{{ message }}
{% if 'Imported' in message %}
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Transactions</a>
{% endif %}
</div>
{% endif %}
<div class="card-wide">
<h2>Upload a Bank or Credit Card Statement</h2>
<p class="helper-text">Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.</p>
<form method="POST" enctype="multipart/form-data" class="import-form">
<div class="form-group">
<label for="account">Account Name</label>
<input type="text" id="account" name="account" placeholder="e.g. Chase Checking, Discover, Affirm" required>
</div>
<div class="form-group">
<label for="statement">Statement File</label>
<div class="file-drop-area">
<input type="file" id="statement" name="statement" accept=".pdf,.csv" required>
<p>Click to choose a file, or drag and drop here</p>
<p class="file-types">PDF or CSV</p>
</div>
</div>
<button type="submit" class="btn btn-primary btn-large">Import</button>
</form>
</div>
<div class="card-wide tips">
<h3>How to export your statements</h3>
<ul>
<li><strong>WestConsin Credit Union:</strong> Log in at westconsin.org → select your account → look for a <em>Download</em> or <em>Export</em> button near the top of your transaction list → choose CSV or Excel format → pick your date range and download</li>
<li><strong>Most other banks:</strong> Log in → Account Details or Transaction History → Export / Download → choose CSV</li>
<li><strong>Affirm:</strong> affirm.com → Account → Payment History → Export (note: their export is often broken — manual entry may be needed)</li>
<li><strong>Klarna:</strong> klarna.com → Purchases → Export CSV</li>
<li><strong>Afterpay:</strong> afterpay.com → Orders → Export</li>
</ul>
<p class="helper-text" style="margin-top:0.75rem;">If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.</p>
</div>
{% endblock %}

277
templates/income.html Normal file
View File

@@ -0,0 +1,277 @@
{% extends "base.html" %}
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<div class="page-header">
<h1>Income — {{ month_name }} {{ year }}</h1>
<div class="month-nav">
<a href="{{ url_for('income_view', year=prev_year, month=prev_month) }}" class="btn btn-ghost"> Prev</a>
<a href="{{ url_for('income_view', year=next_year, month=next_month) }}" class="btn btn-ghost">Next </a>
</div>
</div>
<!-- YTD Summary Cards -->
<div class="card-grid" style="margin-bottom:1.5rem;">
<div class="card">
<div class="card-label">{{ month_name }} Total</div>
<div class="card-value">${{ month_total | money }}</div>
</div>
<div class="card">
<div class="card-label">{{ year }} YTD Total</div>
<div class="card-value">${{ ytd_total | money }}</div>
</div>
<div class="card">
<div class="card-label">Monthly Average</div>
<div class="card-value">
{% set months_with_income = ytd_by_month | length %}
${{ (ytd_total / months_with_income if months_with_income else 0) | money }}
</div>
</div>
</div>
<!-- Tax estimate banner -->
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
<div>
<div style="font-weight:600;margin-bottom:0.2rem;">🧾 {{ year }} Tax Estimate — based on ${{ ytd_total | money }} YTD income</div>
<div style="font-size:0.82rem;color:var(--text-light);">
~${{ tax_estimate.total | money }} owed &nbsp;·&nbsp;
${{ tax_set_aside | money }} set aside &nbsp;·&nbsp;
{% if tax_still_needed > 0 %}
<span style="color:#c07070;font-weight:600;">${{ tax_still_needed | money }} still needed</span>
{% else %}
<span style="color:#5A9E68;font-weight:600;">fully covered ✓</span>
{% endif %}
</div>
</div>
<a href="{{ url_for('taxes_view') }}" class="btn btn-ghost" style="font-size:0.82rem;">View Tax Details →</a>
</div>
<!-- This Month by Stream -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color: #A0C5A8;">
<h3>{{ month_name }} {{ year }} — by Stream</h3>
</div>
<table class="budget-table">
<thead>
<tr><th>Stream</th><th>Owner</th><th>This Month</th><th>YTD</th><th></th></tr>
</thead>
<tbody>
{% for s in income_streams %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.72rem;">{{ s.owner }}</span></td>
<td class="amount">${{ by_stream.get(s.name, 0) | money }}</td>
<td class="amount muted">${{ ytd_by_stream.get(s.name, 0) | money }}</td>
<td>
<button class="btn btn-ghost" style="font-size:0.75rem;padding:0.2rem 0.5rem;"
onclick="openLogModal('{{ s.name }}')">+ Log</button>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amount">${{ month_total | money }}</td>
<td class="amount muted">${{ ytd_total | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<!-- YTD by Month -->
{% if ytd_by_month %}
<div class="budget-section" style="margin-top:1.5rem;">
<div class="budget-section-header" style="border-left-color: #C5BBA0;">
<h3>{{ year }} — Month by Month</h3>
</div>
<div style="padding:1rem 0.5rem;">
<canvas id="income-monthly-chart" height="80"></canvas>
</div>
<table class="budget-table" style="margin-top:0.5rem;">
<thead><tr><th>Month</th><th>Total Income</th></tr></thead>
<tbody>
{% for mk in sorted_months %}
{% set amt = ytd_by_month[mk] %}
<tr>
<td>{{ mk }}</td>
<td class="amount">${{ amt | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>YTD Total</td>
<td class="amount">${{ ytd_total | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
<!-- Log Entries for This Month -->
{% if entries %}
<div class="budget-section" style="margin-top:1.5rem;">
<div class="budget-section-header" style="border-left-color: #B0CDD6;">
<h3>{{ month_name }} Entries</h3>
</div>
<table class="budget-table">
<thead><tr><th>Stream</th><th>Amount</th><th>Date Logged</th><th>Note</th><th></th></tr></thead>
<tbody>
{% for e in entries | sort(attribute='date', reverse=True) %}
<tr>
<td>{{ e.stream }}</td>
<td class="amount">${{ e.amount | money }}</td>
<td class="muted">{{ e.date }}</td>
<td class="muted">{{ e.note or '—' }}</td>
<td>
<form method="POST" action="/income/delete/{{ e.id }}" style="display:inline;">
<input type="hidden" name="year" value="{{ year }}">
<input type="hidden" name="month" value="{{ month }}">
<button type="submit" class="btn btn-ghost" style="font-size:0.72rem;padding:0.15rem 0.4rem;color:#c07070;"
onclick="return confirm('Delete this entry?')"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Log Income Modal -->
<div id="income-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:100;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:1.5rem;min-width:320px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
<h3 style="margin:0 0 1rem;">Log Income</h3>
<form method="POST" action="/income/log">
<input type="hidden" name="month_str" value="{{ '%04d-%02d' | format(year, month) }}">
<div style="margin-bottom:0.75rem;">
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Stream</label>
<select name="stream" id="modal-stream" class="form-input" style="width:100%;">
{% for s in income_streams %}
<option value="{{ s.name }}">{{ s.name }} ({{ s.owner }})</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom:0.75rem;">
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Amount</label>
<input type="number" name="amount" step="0.01" min="0" class="form-input budget-input" style="width:100%;" placeholder="0.00" required>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.85rem;margin-bottom:0.25rem;">Note (optional)</label>
<input type="text" name="note" class="form-input budget-input" style="width:100%;" placeholder="e.g. paycheck, client payment…">
</div>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script>
function openLogModal(stream) {
const modal = document.getElementById('income-modal');
modal.style.display = 'flex';
if (stream) {
document.getElementById('modal-stream').value = stream;
}
}
function closeModal() {
document.getElementById('income-modal').style.display = 'none';
}
document.getElementById('income-modal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
</script>
<div style="margin-top:1.5rem;">
<button class="btn btn-primary" onclick="openLogModal('')">+ Log Income</button>
</div>
<!-- Bulk Backfill -->
<div class="budget-section" style="margin-top:2rem;">
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
<h3>Backfill Past Months</h3>
</div>
<p class="muted" style="padding:0.5rem 0 1rem;">Enter totals for any month you haven't logged yet. Leave blank to skip. Existing entries won't be duplicated.</p>
<form method="POST" action="/income/backfill">
<input type="hidden" name="year" value="{{ year }}">
<div style="overflow-x:auto;">
<table class="budget-table">
<thead>
<tr>
<th>Month</th>
{% for s in income_streams %}
<th style="min-width:110px;">
{{ s.name }}<br>
<span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for m in range(1, 13) %}
{% set mk = '%04d-%02d' | format(year, m) %}
{% set mn = month_names[m] %}
{% set is_current = (m == month) %}
<tr{% if is_current %} style="background:#f5f2ee;"{% endif %}>
<td>
<strong>{{ mn }}</strong>
{% if is_current %}<span class="muted" style="font-size:0.75rem;"> (current)</span>{% endif %}
{% if ytd_by_month.get(mk) %}
<span style="font-size:0.72rem;color:#7A9E78;margin-left:0.25rem;">✓ ${{ "%.0f"|format(ytd_by_month[mk]) }}</span>
{% endif %}
</td>
{% for s in income_streams %}
<td>
<input type="number" step="0.01" min="0"
name="amt_{{ m }}_{{ s.name | replace(' ','_') }}"
class="budget-input" style="width:100px;"
placeholder="0.00">
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="margin-top:1rem;">
<button type="submit" class="btn btn-primary">Save All</button>
</div>
</form>
</div>
<script>
{% if ytd_by_month %}
const incomeLabels = {{ sorted_months | tojson }};
const incomeValues = [{% for mk in sorted_months %}{{ ytd_by_month[mk] }},{% endfor %}];
new Chart(document.getElementById('income-monthly-chart'), {
type: 'bar',
data: {
labels: incomeLabels,
datasets: [{
label: 'Income',
data: incomeValues,
backgroundColor: '#7AB87A',
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(2) } }
},
scales: {
x: { grid: { display: false } },
y: { grid: { color: '#f0ece6' }, ticks: { callback: v => '$' + v.toLocaleString() } }
},
animation: { duration: 400 },
}
});
{% endif %}
</script>
{% endblock %}

385
templates/index.html Normal file
View File

@@ -0,0 +1,385 @@
{% extends "base.html" %}
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<div class="page-header">
<h1>{{ month_name }} {{ year }} — Overview</h1>
</div>
<!-- Top summary cards -->
<div class="card-grid" style="margin-bottom:1.5rem;">
<div class="card">
<div class="card-label">Income</div>
<div class="card-value" style="color:#5A9E68;">${{ total_income | money }}</div>
</div>
<div class="card">
<div class="card-label">Spent</div>
<div class="card-value">${{ (total_spending + total_bills) | money }}</div>
</div>
<div class="card">
<div class="card-label">Saved to Funds</div>
<div class="card-value">${{ total_sinking | money }}</div>
</div>
<div class="card" style="border: 2px solid {% if net >= 0 %}#A8C5A0{% else %}#C57A7A{% endif %};">
<div class="card-label">{{ 'Left Over' if net >= 0 else 'Over Budget' }}</div>
<div class="card-value" style="color:{% if net >= 0 %}#5A9E68{% else %}#c07070{% endif %};">
${{ net | abs | money }}
</div>
</div>
</div>
<!-- Plain-English summary -->
{% if month_summary %}
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;">
<div style="font-size:0.78rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);margin-bottom:0.5rem;">How's it going?</div>
{% for line in month_summary %}
<div style="font-size:0.9rem;color:var(--text-dark);margin-bottom:0.25rem;">{{ line | bold_md }}</div>
{% endfor %}
</div>
{% endif %}
<!-- Cash flow bar -->
{% 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 %}
<div style="margin-bottom:2rem;">
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--text-mid);margin-bottom:0.35rem;">
<span>Where did the money go?</span>
<span>${{ total_income | money }} income this month</span>
</div>
<div style="display:flex;height:18px;border-radius:999px;overflow:hidden;background:#EDE8E2;">
<div style="width:{{ spent_pct }}%;background:#C57A7A;" title="Spent"></div>
<div style="width:{{ sinking_pct }}%;background:#A8C5A0;" title="Savings"></div>
</div>
<div style="display:flex;gap:1.25rem;font-size:0.78rem;color:var(--text-mid);margin-top:0.4rem;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#C57A7A;margin-right:3px;"></span>Spent {{ spent_pct }}%</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#A8C5A0;margin-right:3px;"></span>Saved {{ sinking_pct }}%</span>
</div>
</div>
{% endif %}
{% if spending_by_cat %}
<div style="display:flex;gap:1.5rem;align-items:center;margin-bottom:2rem;flex-wrap:wrap;">
<div style="flex:0 0 220px;"><canvas id="overview-donut" width="220" height="220"></canvas></div>
<div style="flex:1;min-width:180px;">
<div style="font-size:0.82rem;font-weight:600;margin-bottom:0.5rem;color:var(--text-light);text-transform:uppercase;letter-spacing:0.05em;">{{ month_name }} Spending</div>
{% for cat, amt in spending_by_cat | dictsort(by='value', reverse=true) %}
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.83rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:{{ colors.get(cat,'#ddd') }};flex-shrink:0;"></span>
<span style="flex:1;">{{ cat }}</span>
<span style="font-weight:600;">${{ "%.0f" | format(amt) }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;">
<!-- Left column -->
<div>
<!-- Income by stream -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#7AB87A;">
<h3>Income</h3>
<a href="{{ url_for('income_view', year=year, month=month) }}" class="edit-link">manage →</a>
</div>
{% if income_by_stream %}
<table class="budget-table">
<tbody>
{% for s in income_streams %}
{% set amt = income_by_stream.get(s.name, 0) %}
<tr>
<td>{{ s.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span></td>
<td class="amount {% if amt == 0 %}muted{% endif %}">${{ amt | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total · <span class="muted" style="font-weight:400;font-size:0.82rem;">{{ year }} YTD ${{ "%.0f"|format(income_ytd_total) }}</span></td>
<td class="amount">${{ total_income | money }}</td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No income logged yet. <a href="{{ url_for('income_view', year=year, month=month) }}">Log income →</a></p>
{% endif %}
</div>
<!-- Bills -->
<div class="budget-section" style="margin-top:1.25rem;">
<div class="budget-section-header" style="border-left-color:#C57A7A;">
<h3>Bills This Month</h3>
<a href="{{ url_for('bill_calendar', year=year, month=month) }}" class="edit-link">calendar →</a>
</div>
{% if monthly_bills %}
<table class="budget-table">
<tbody>
{% for b in monthly_bills %}
<tr>
<td>{{ b.name }}</td>
<td class="muted">day {{ b.due_day }}</td>
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amount">${{ total_bills | money }}</td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No bills this month. <a href="{{ url_for('bills_list') }}">Manage bills →</a></p>
{% endif %}
</div>
<!-- Sinking contributions -->
{% if sinking_contributions %}
<div class="budget-section" style="margin-top:1.25rem;">
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
<h3>Saved to Funds</h3>
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage →</a>
</div>
<table class="budget-table">
<tbody>
{% for name, amt in sinking_contributions.items() %}
<tr>
<td>{{ name }}</td>
<td class="amount">${{ amt | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount">${{ total_sinking | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</div>
<!-- Right column -->
<div>
<!-- Cash accounts -->
{% if cash_accounts %}
<div class="budget-section" style="margin-bottom:1.25rem;">
<div class="budget-section-header" style="border-left-color:#7AB87A;">
<h3>Account Balances</h3>
<a href="{{ url_for('net_worth_view') }}" class="edit-link">manage →</a>
</div>
<table class="budget-table">
<tbody>
{% for a in cash_accounts %}
<tr>
<td>{{ a.name }}</td>
<td class="muted" style="font-size:0.78rem;">{{ a.type or '' }}</td>
<td class="amount">${{ (a.balance or 0) | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total Cash</td>
<td class="amount">${{ total_cash | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% else %}
<div class="budget-section" style="margin-bottom:1.25rem;">
<div class="budget-section-header" style="border-left-color:#7AB87A;">
<h3>Account Balances</h3>
</div>
<p class="muted" style="padding:0.5rem 0;font-size:0.85rem;">No accounts added yet. <a href="{{ url_for('net_worth_view') }}">Add accounts →</a></p>
</div>
{% endif %}
<!-- Spending by category -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Spending by Category</h3>
<a href="{{ url_for('transactions_view') }}" class="edit-link">all transactions →</a>
</div>
{% if spending_by_cat %}
{% set max_spend = spending_by_cat.values() | max %}
<table class="budget-table">
<thead><tr><th>Category</th><th>Budgeted</th><th>Actual</th><th></th></tr></thead>
<tbody>
{% 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 %}
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;"></span>{% endif %}</td>
<td class="amount">
${{ amt | money }}
{% if over %}<span style="font-size:0.7rem;color:#c07070;"> over</span>{% endif %}
</td>
<td style="width:28%;padding-right:0.5rem;">
<div style="background:#EDE8E2;border-radius:4px;height:8px;">
<div style="background:{{ colors.get(cat,'#bbb') }};border-radius:4px;height:8px;width:{{ [(amt/max_spend*100)|int,100]|min }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount muted">{% if budgeted_by_cat %}${{ "%.2f"|format(budgeted_by_cat.values()|sum) }}{% endif %}</td>
<td class="amount">${{ total_spending | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No categorized transactions yet. <a href="{{ url_for('review') }}">Review transactions →</a></p>
{% endif %}
</div>
<!-- Recent transactions -->
{% if recent %}
<div class="budget-section" style="margin-top:1.25rem;">
<div class="budget-section-header" style="border-left-color:#8AABCC;">
<h3>Recent Transactions</h3>
<a href="{{ url_for('transactions_view') }}" class="edit-link">view all →</a>
</div>
<table class="budget-table">
<tbody>
{% for t in recent %}
<tr>
<td class="muted" style="white-space:nowrap;">{{ t.date }}</td>
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.description }}</td>
<td class="amount">${{ t.amount | money }}</td>
<td><span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.72rem;">{{ t.category }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
<!-- Budget vs Actual chart -->
{% if budgeted_by_cat %}
<div class="budget-section" style="margin-top:1.5rem;">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Budget vs Actual — {{ month_name }}</h3>
</div>
<div style="position:relative;height:{{ [budgeted_by_cat | length * 42, 200] | max }}px;">
<canvas id="budget-actual-chart"></canvas>
</div>
</div>
{% endif %}
<!-- Tax estimate widget -->
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-top:1.5rem;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;">
<div>
<div style="font-weight:600;margin-bottom:0.2rem;">🧾 {{ year }} Tax Estimate</div>
<div style="font-size:0.82rem;color:var(--text-light);">
${{ tax_estimate.total | money }} estimated owed &nbsp;·&nbsp;
${{ tax_set_aside | money }} set aside &nbsp;·&nbsp;
{% if tax_still_needed > 0 %}
<span style="color:#c07070;font-weight:600;">${{ tax_still_needed | money }} still needed</span>
{% else %}
<span style="color:#5A9E68;font-weight:600;">fully covered ✓</span>
{% endif %}
</div>
</div>
<a href="{{ url_for('taxes_view') }}" class="btn btn-ghost" style="font-size:0.82rem;">View Details →</a>
</div>
<!-- Net summary bar -->
<div class="leftover-bar {% if net < 0 %}leftover-negative{% endif %}" style="margin-top:2rem;">
<div>
<div style="font-weight:600;">{{ month_name }} {{ year }} — Net</div>
<div style="font-size:0.82rem;color:var(--text-light);margin-top:0.2rem;">
${{ "%.2f"|format(total_income) }} income ${{ "%.2f"|format(total_spending + total_bills) }} spent ${{ "%.2f"|format(total_sinking) }} saved
</div>
</div>
<div class="leftover-amount">{% if net >= 0 %}+{% endif %}${{ net | money }}</div>
</div>
<script>
{% if spending_by_cat %}
const donutLabels = {{ spending_by_cat.keys() | list | tojson }};
const donutValues = {{ spending_by_cat.values() | list | tojson }};
const donutColors = [{% for cat in spending_by_cat %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
new Chart(document.getElementById('overview-donut'), {
type: 'doughnut',
data: {
labels: donutLabels,
datasets: [{
data: donutValues,
backgroundColor: donutColors,
borderWidth: 2,
borderColor: '#faf7f2',
hoverOffset: 6,
}]
},
options: {
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.toFixed(2) } }
},
animation: { duration: 400 },
}
});
{% endif %}
{% if budgeted_by_cat %}
const baLabels = {{ budgeted_by_cat.keys() | list | tojson }};
const baBudgeted = {{ budgeted_by_cat.values() | list | tojson }};
const baActual = [{% for cat in budgeted_by_cat %}{{ spending_by_cat.get(cat, 0) }},{% endfor %}];
const baColors = [{% for cat in budgeted_by_cat %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
new Chart(document.getElementById('budget-actual-chart'), {
type: 'bar',
data: {
labels: baLabels,
datasets: [
{
label: 'Budgeted',
data: baBudgeted,
backgroundColor: baColors.map(c => c + '55'),
borderColor: baColors,
borderWidth: 1.5,
borderRadius: 4,
},
{
label: 'Actual',
data: baActual,
backgroundColor: baColors,
borderRadius: 4,
}
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 12 }, padding: 16 } },
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.x.toFixed(2) } }
},
scales: {
x: { ticks: { callback: v => '$' + v, font: { size: 11 } }, grid: { color: '#EDE8E2' } },
y: { ticks: { font: { size: 12 } } }
}
}
});
{% endif %}
</script>
{% endblock %}

57
templates/login.html Normal file
View File

@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Moon Household Budget</title>
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#FAF7F2;
display:flex; min-height:100vh; align-items:center; justify-content:center; margin:0; color:#3A3330; }
.card { background:#fff; border:1px solid #EDE8E2; border-radius:16px; padding:2rem; max-width:380px;
width:90%; text-align:center; box-shadow:0 4px 16px rgba(80,60,50,0.08); }
h1 { font-size:1.3rem; margin:0 0 .25rem; }
p.sub { color:#7A6E68; margin:0 0 1.5rem; }
button { background:#4338ca; color:#fff; border:0; border-radius:10px; padding:.8rem 1.2rem;
font:inherit; font-weight:600; cursor:pointer; width:100%; }
button:disabled { opacity:.5; }
.msg { min-height:1.2rem; margin-top:.9rem; font-size:.9rem; color:#C57A6E; }
a { color:#4338ca; font-size:.85rem; }
</style>
</head>
<body>
<div class="card">
<h1>🌙 Moon Household Budget</h1>
<p class="sub">Sign in with your passkey</p>
<button id="login">🔑 Sign in with passkey</button>
<div class="msg" id="msg"></div>
<p style="margin-top:1.25rem;"><a href="/enroll">Set up a new device</a></p>
</div>
<script>
const { startAuthentication } = SimpleWebAuthnBrowser;
const msg = document.getElementById('msg');
const btn = document.getElementById('login');
btn.onclick = async () => {
btn.disabled = true; msg.textContent = 'Waiting for your passkey…';
try {
const opts = await (await fetch('/auth/login/begin', { method: 'POST' })).json();
const assertion = await startAuthentication(opts);
const res = await fetch('/auth/login/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertion),
});
const data = await res.json();
if (data.ok) {
window.location = new URLSearchParams(location.search).get('next') || '/';
} else {
msg.textContent = 'Sign-in failed: ' + (data.error || 'try again');
btn.disabled = false;
}
} catch (e) {
msg.textContent = 'Sign-in failed: ' + e.message;
btn.disabled = false;
}
};
</script>
</body>
</html>

244
templates/month.html Normal file
View File

@@ -0,0 +1,244 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>{{ month_name }} {{ year }} — Monthly Summary</h1>
<div class="month-nav">
<a href="{{ url_for('month_summary', year=prev_year, month=prev_month) }}" class="btn btn-ghost"> Prev</a>
<a href="{{ url_for('month_summary', year=next_year, month=next_month) }}" class="btn btn-ghost">Next </a>
</div>
</div>
<!-- Top-line summary cards -->
<div class="card-grid" style="margin-bottom:1.5rem;">
<div class="card">
<div class="card-label">Total Income</div>
<div class="card-value" style="color:#5A9E68;">${{ total_income | money }}</div>
</div>
<div class="card">
<div class="card-label">Total Spent</div>
<div class="card-value">${{ (total_spending + total_bills) | money }}</div>
</div>
<div class="card">
<div class="card-label">Saved to Funds</div>
<div class="card-value">${{ total_sinking | money }}</div>
</div>
<div class="card" style="border: 2px solid {% if net >= 0 %}#A8C5A0{% else %}#C57A7A{% endif %};">
<div class="card-label">{{ 'Left Over' if net >= 0 else 'Over Budget' }}</div>
<div class="card-value" style="color:{% if net >= 0 %}#5A9E68{% else %}#c07070{% endif %};">
${{ net | abs | money }}
</div>
</div>
</div>
<!-- Cash flow bar -->
{% 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 %}
<div style="margin-bottom:2rem;">
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--text-mid);margin-bottom:0.35rem;">
<span>Where did the money go?</span>
<span>${{ total_income | money }} income</span>
</div>
<div style="display:flex;height:18px;border-radius:999px;overflow:hidden;background:#EDE8E2;">
<div style="width:{{ spent_pct }}%;background:#C57A7A;" title="Spent"></div>
<div style="width:{{ sinking_pct }}%;background:#A8C5A0;" title="Savings"></div>
</div>
<div style="display:flex;gap:1.25rem;font-size:0.78rem;color:var(--text-mid);margin-top:0.4rem;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#C57A7A;margin-right:3px;"></span>Spent {{ spent_pct }}%</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#A8C5A0;margin-right:3px;"></span>Saved {{ sinking_pct }}%</span>
{% if net > 0 %}
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EDE8E2;border:1px solid #ccc;margin-right:3px;"></span>Left over {{ [100 - spent_pct - sinking_pct, 0] | max }}%</span>
{% endif %}
</div>
</div>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;">
<!-- Left column -->
<div>
<!-- Income -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#7AB87A;">
<h3>Income</h3>
<a href="{{ url_for('income_view', year=year, month=month) }}" class="edit-link">manage</a>
</div>
{% if income_by_stream %}
<table class="budget-table">
<tbody>
{% for s in income_streams %}
{% set amt = income_by_stream.get(s.name, 0) %}
{% if amt > 0 %}
<tr>
<td>{{ s.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(s.owner,'#ddd') }};font-size:0.68rem;">{{ s.owner }}</span></td>
<td class="amount">${{ amt | money }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amount">${{ total_income | money }}</td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No income logged yet. <a href="{{ url_for('income_view', year=year, month=month) }}">Log income →</a></p>
{% endif %}
</div>
<!-- Bills -->
<div class="budget-section" style="margin-top:1.25rem;">
<div class="budget-section-header" style="border-left-color:#C57A7A;">
<h3>Bills</h3>
<a href="{{ url_for('bills_list') }}" class="edit-link">manage</a>
</div>
{% if monthly_bills %}
<table class="budget-table">
<tbody>
{% for b in monthly_bills %}
<tr>
<td>{{ b.name }}</td>
<td class="muted">day {{ b.due_day }}</td>
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amount">${{ total_bills | money }}</td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No bills this month.</p>
{% endif %}
</div>
<!-- Sinking Funds -->
{% if sinking_contributions %}
<div class="budget-section" style="margin-top:1.25rem;">
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
<h3>Sinking Fund Contributions</h3>
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage</a>
</div>
<table class="budget-table">
<tbody>
{% for name, amt in sinking_contributions.items() %}
<tr>
<td>{{ name }}</td>
<td class="amount">${{ amt | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total Saved</td>
<td class="amount">${{ total_sinking | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</div>
<!-- Right column -->
<div>
<!-- Spending by category -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Spending by Category</h3>
<a href="{{ url_for('transactions_view') }}" class="edit-link">view transactions</a>
</div>
{% if spending_by_cat %}
{% set max_spend = spending_by_cat.values() | max %}
<table class="budget-table">
<thead><tr><th>Category</th><th>Budgeted</th><th>Actual</th><th></th></tr></thead>
<tbody>
{% 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 %}
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;"></span>{% endif %}</td>
<td class="amount">
${{ amt | money }}
{% if over %}<span style="font-size:0.7rem;color:#c07070;"> over</span>{% endif %}
</td>
<td style="width:30%;padding-right:0.5rem;">
<div style="background:#EDE8E2;border-radius:4px;height:8px;">
<div style="background:{{ colors.get(cat,'#bbb') }};border-radius:4px;height:8px;width:{{ [(amt / max_spend * 100)|int, 100]|min }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount muted">{% if budgeted_by_cat %}${{ budgeted_by_cat.values()|sum|money }}{% endif %}</td>
<td class="amount">${{ total_spending | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No categorized transactions yet. <a href="{{ url_for('review') }}">Review transactions →</a></p>
{% endif %}
</div>
<!-- Debt payments -->
{% if debt_payments %}
<div class="budget-section" style="margin-top:1.25rem;">
<div class="budget-section-header" style="border-left-color:#C57A7A;">
<h3>Debt Payments</h3>
<a href="{{ url_for('debts_view', year=year, month=month) }}" class="edit-link">manage</a>
</div>
<table class="budget-table">
<thead><tr><th>Debt</th><th>Owner</th><th>Payment</th><th>Extra</th></tr></thead>
<tbody>
{% for d in debt_payments %}
<tr>
<td>{{ d.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.68rem;">{{ d.owner }}</span></td>
<td class="amount">${{ d.payment | money }}</td>
<td class="amount">{% if d.extra > 0 %}<span style="color:#5A9E68;">+${{ d.extra | money }}</span>{% else %}<span class="muted"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total Paid</td>
<td class="amount">${{ total_debt_paid | money }}</td>
<td class="amount" style="color:#5A9E68;">{% if total_extra_paid > 0 %}+${{ total_extra_paid | money }} extra{% endif %}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</div>
</div>
<!-- Bottom net summary -->
<div class="leftover-bar {% if net < 0 %}leftover-negative{% endif %}" style="margin-top:2rem;">
<div>
<div style="font-weight:600;font-size:1rem;">{{ month_name }} {{ year }} — Final Summary</div>
<div style="font-size:0.82rem;color:var(--text-light);margin-top:0.2rem;">
${{ "%.2f"|format(total_income) }} income
${{ "%.2f"|format(total_spending + total_bills) }} spent
${{ "%.2f"|format(total_sinking) }} saved
</div>
</div>
<div class="leftover-amount">
{% if net >= 0 %}+{% endif %}${{ net | money }}
</div>
</div>
{% endblock %}

157
templates/mortgage.html Normal file
View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Mortgage Tracker</h1>
</div>
<!-- Setup form -->
<div class="card-wide" style="margin-bottom:1.75rem;">
<h2>Mortgage Details</h2>
<p class="helper-text">Fill these in once you close on your home. Everything calculates automatically.</p>
<form method="POST" class="bill-form">
<div class="bill-form-grid">
<div class="form-group">
<label>Loan Amount</label>
<input type="number" name="principal" step="0.01" min="0"
value="{{ principal or '' }}" placeholder="e.g. 280000">
</div>
<div class="form-group">
<label>Interest Rate (APR %)</label>
<input type="number" name="annual_rate" step="0.001" min="0" max="20"
value="{{ annual_rate or '' }}" placeholder="e.g. 6.875">
</div>
<div class="form-group">
<label>Loan Term</label>
<select name="term_years">
<option value="30" {% if term_years == 30 %}selected{% endif %}>30 years</option>
<option value="20" {% if term_years == 20 %}selected{% endif %}>20 years</option>
<option value="15" {% if term_years == 15 %}selected{% endif %}>15 years</option>
<option value="10" {% if term_years == 10 %}selected{% endif %}>10 years</option>
</select>
</div>
<div class="form-group">
<label>First Payment Date</label>
<input type="date" name="start_date" value="{{ start_date or '' }}">
</div>
<div class="form-group">
<label>Extra Monthly Payment <span class="optional">(optional)</span></label>
<input type="number" name="extra_monthly" step="0.01" min="0"
value="{{ extra_monthly or '' }}" placeholder="0.00">
</div>
</div>
<button type="submit" class="btn btn-primary">Save & Calculate</button>
</form>
</div>
{% if principal %}
<!-- Summary cards -->
<div class="mortgage-summary">
<div class="card">
<div class="card-label">Monthly Payment</div>
<div class="card-value">${{ base_payment | money }}</div>
</div>
<div class="card">
<div class="card-label">Current Balance</div>
<div class="card-value">${{ "{:,.0f}".format(current_bal) }}</div>
</div>
<div class="card">
<div class="card-label">Total Interest (standard)</div>
<div class="card-value">${{ "{:,.0f}".format(total_interest_standard) }}</div>
</div>
<div class="card">
<div class="card-label">Payoff</div>
<div class="card-value">{{ standard_months }} mo</div>
</div>
</div>
<!-- Strategy comparison -->
<section class="section">
<h2>Payment Strategies</h2>
<div class="mortgage-strategies">
<!-- Standard -->
<div class="mortgage-strategy-card" style="border-top:4px solid #B8B8B8;">
<div class="strategy-label">Standard Monthly</div>
<div class="strategy-subtitle">Pay ${{ base_payment | money }}/month</div>
<div class="strategy-stat"><span>Payoff</span><strong>{{ standard_months }} months ({{ (standard_months / 12) | round(1) }} yrs)</strong></div>
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(total_interest_standard) }}</strong></div>
<div class="strategy-stat"><span>Interest saved</span><strong></strong></div>
</div>
<!-- Biweekly -->
<div class="mortgage-strategy-card" style="border-top:4px solid #A8C5A0;">
<div class="strategy-label">Biweekly Payments</div>
<div class="strategy-subtitle">Pay ${{ (base_payment / 2) | money }} every 2 weeks</div>
<div class="strategy-stat"><span>Payoff</span><strong>{{ bi_months_total }} months ({{ bi_years }} yrs)</strong></div>
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(bi_interest) }}</strong></div>
<div class="strategy-stat"><span>Interest saved</span>
<strong class="accent">${{ "{:,.2f}".format(interest_saved_biweekly) }}
({{ months_saved_biweekly }} months sooner)</strong>
</div>
<div class="strategy-badge">One extra payment per year</div>
</div>
<!-- Extra monthly -->
{% if extra_monthly > 0 %}
<div class="mortgage-strategy-card" style="border-top:4px solid #D4B8C5;">
<div class="strategy-label">Extra ${{ "%.0f" | format(extra_monthly) }}/Month</div>
<div class="strategy-subtitle">Pay ${{ (base_payment + extra_monthly) | money }}/month total</div>
<div class="strategy-stat"><span>Payoff</span><strong>{{ extra_months }} months ({{ (extra_months / 12) | round(1) }} yrs)</strong></div>
<div class="strategy-stat"><span>Total interest</span><strong>${{ "{:,.2f}".format(total_interest_extra) }}</strong></div>
<div class="strategy-stat"><span>Interest saved</span>
<strong class="accent">${{ "{:,.2f}".format(interest_saved_extra) }}
({{ months_saved_extra }} months sooner)</strong>
</div>
</div>
{% else %}
<div class="mortgage-strategy-card mortgage-strategy-empty" style="border-top:4px solid #D4B8C5;">
<div class="strategy-label">Extra Monthly Payment</div>
<div class="strategy-subtitle">Enter an extra monthly amount above to see the impact</div>
</div>
{% endif %}
</div>
</section>
<!-- Amortization schedule -->
<section class="section">
<details class="amortization-details">
<summary class="amortization-toggle">
View Full Amortization Schedule
<span class="muted">({{ schedule | length }} payments)</span>
</summary>
<div class="amortization-wrap">
<table class="txn-table txn-table-full amortization-table">
<thead>
<tr>
<th>#</th>
<th>Date</th>
<th>Payment</th>
<th>Principal</th>
<th>Interest</th>
<th>Balance</th>
</tr>
</thead>
<tbody>
{% for row in schedule %}
<tr class="{% if row.interest > row.principal %}amort-interest-heavy{% endif %}">
<td class="muted">{{ row.month }}</td>
<td>{{ row.date }}</td>
<td class="amount">${{ row.payment | money }}</td>
<td class="amount">${{ row.principal | money }}</td>
<td class="amount amort-interest">${{ row.interest | money }}</td>
<td class="amount">${{ "{:,.2f}".format(row.balance) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
</section>
{% else %}
<div class="empty-state">
<p>Enter your mortgage details above once you close on your home and everything will calculate automatically.</p>
</div>
{% endif %}
{% endblock %}

225
templates/net_worth.html Normal file
View File

@@ -0,0 +1,225 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Net Worth</h1>
<span class="muted" style="font-size:0.85rem;">Assets minus Liabilities</span>
</div>
<!-- Big Net Worth Number -->
<div class="net-worth-hero {% if net_worth >= 0 %}net-worth-positive{% else %}net-worth-negative{% endif %}">
<div class="net-worth-label">Current Net Worth</div>
<div class="net-worth-number">${{ net_worth | money }}</div>
<div class="net-worth-sub">
<span>Assets: <strong>${{ total_assets | money }}</strong></span>
<span style="margin:0 0.75rem;opacity:0.5;"></span>
<span>Debts: <strong>${{ total_debts | money }}</strong></span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:1.5rem;">
<!-- ASSETS COLUMN -->
<div>
<h2 style="margin-bottom:1rem;color:var(--accent);">Assets</h2>
<!-- Cash & Accounts -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#7AB87A;">
<h3>Cash & Bank Accounts</h3>
<span class="muted" style="font-size:0.82rem;">${{ total_cash | money }}</span>
</div>
<table class="budget-table">
<thead><tr><th>Account</th><th>Owner</th><th>Balance</th><th></th></tr></thead>
<tbody>
{% for acc in accounts %}
<tr>
<td>{{ acc.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(acc.owner,'#ddd') }};font-size:0.72rem;">{{ acc.owner }}</span></td>
<td class="amount">
<input type="number" step="0.01" class="budget-input"
value="{{ acc.balance }}"
onchange="updateAccount('{{ acc.id }}', this.value)"
style="width:110px;">
</td>
<td>
<form method="POST" action="/net-worth/account/delete/{{ acc.id }}" style="display:inline;">
<button type="submit" class="debt-delete-btn" onclick="return confirm('Remove this account?')"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total Cash</td>
<td class="amount">${{ total_cash | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
<!-- Add Account -->
<details style="margin-top:0.75rem;">
<summary class="btn btn-ghost" style="display:inline-block;cursor:pointer;font-size:0.82rem;">+ Add Account</summary>
<form method="POST" action="/net-worth/account/add" style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem;align-items:flex-end;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.78rem;">Account Name</label>
<input type="text" name="name" placeholder="e.g. WestConsin Checking" class="budget-input" required>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.78rem;">Owner</label>
<select name="owner" class="budget-input">
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
<option value="Both">Both</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.78rem;">Balance</label>
<input type="number" step="0.01" name="balance" placeholder="0.00" class="budget-input" style="width:110px;">
</div>
<button type="submit" class="btn btn-primary" style="align-self:flex-end;">Add</button>
</form>
</details>
</div>
<!-- Sinking Funds -->
<div class="budget-section" style="margin-top:1rem;">
<div class="budget-section-header" style="border-left-color:#C5BBA0;">
<h3>Sinking Funds</h3>
<a href="{{ url_for('sinking_view') }}" class="edit-link">manage</a>
<span class="muted" style="font-size:0.82rem;">${{ total_sinking | money }}</span>
</div>
<table class="budget-table">
<tbody>
{% for f in sinking_funds %}
<tr>
<td>{{ f.name }}</td>
<td class="amount">${{ (f.balance or 0) | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount">${{ total_sinking | money }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- Vehicles -->
{% if personal_cars %}
<div class="budget-section" style="margin-top:1rem;">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Vehicles</h3>
<a href="{{ url_for('assets_view') }}" class="edit-link">manage</a>
<span class="muted" style="font-size:0.82rem;">${{ total_vehicles | money }}</span>
</div>
<table class="budget-table">
<tbody>
{% for car in personal_cars %}
<tr>
<td>{{ car.display_name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(car.owner,'#ddd') }};font-size:0.72rem;">{{ car.owner }}</span></td>
<td class="amount">
{% if car.estimated_value %}
${{ car.estimated_value | float | money }}
{% else %}
<span class="muted">no value set</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amount">${{ total_vehicles | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
<!-- Other Assets -->
{% if other_assets %}
<div class="budget-section" style="margin-top:1rem;">
<div class="budget-section-header" style="border-left-color:#A08CC5;">
<h3>Other Assets</h3>
<a href="{{ url_for('assets_view') }}" class="edit-link">manage</a>
<span class="muted" style="font-size:0.82rem;">${{ total_other_assets | money }}</span>
</div>
<table class="budget-table">
<tbody>
{% for a in other_assets %}
<tr>
<td>{{ a.name }}</td>
<td class="muted" style="font-size:0.8rem;">{{ a.category }}</td>
<td class="amount">${{ a.value | float | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total</td>
<td class="amount">${{ total_other_assets | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</div><!-- end assets column -->
<!-- LIABILITIES COLUMN -->
<div>
<h2 style="margin-bottom:1rem;color:#C57A7A;">Liabilities (Debts)</h2>
{% for cat in debt_categories %}
{% set cat_debts = debts_by_cat[cat] %}
{% if cat_debts %}
<div class="budget-section" style="margin-top:{% if loop.first %}0{% else %}1rem{% endif %};">
<div class="budget-section-header" style="border-left-color:#C57A7A;">
<h3>{{ cat }}</h3>
<span class="muted" style="font-size:0.82rem;">${{ debt_cat_totals[cat] | money }}</span>
</div>
<table class="budget-table">
<tbody>
{% for d in cat_debts %}
<tr>
<td>{{ d.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
<td class="amount">${{ d.current_balance | float | money }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Subtotal</td>
<td class="amount">${{ debt_cat_totals[cat] | money }}</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
{% endfor %}
<div class="leftover-bar" style="margin-top:1rem;">
<span>Total Debt</span>
<span style="font-size:1.1rem;font-weight:700;color:#C57A7A;">${{ total_debts | money }}</span>
</div>
</div><!-- end liabilities column -->
</div>
<script>
function updateAccount(id, value) {
fetch('/net-worth/account/update/' + id, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({balance: value})
}).then(() => location.reload());
}
</script>
{% endblock %}

396
templates/review.html Normal file
View File

@@ -0,0 +1,396 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Review Transactions</h1>
{% if remaining %}<span class="badge">{{ remaining }} remaining</span>{% endif %}
</div>
{% if remaining %}
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<span style="font-size:0.88rem;color:var(--text-mid);">Need to bulk-clear old transactions?</span>
<form method="POST" action="{{ url_for('bulk_ignore_before') }}" onsubmit="return confirm('Ignore all unreviewed transactions before this date?')">
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.85rem;">Ignore everything before</label>
<input type="date" name="before_date" required class="budget-input" style="width:150px;">
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.85rem;">Bulk ignore</button>
</div>
</form>
</div>
{% endif %}
{% if not transaction %}
<div class="empty-state card-wide">
<div class="empty-icon"></div>
<h2>All caught up!</h2>
<p>Every transaction has been reviewed.</p>
<a href="{{ url_for('transactions_view') }}" class="btn btn-primary">View All Transactions</a>
</div>
{% else %}
<div class="review-card">
<div class="review-meta">
<span class="review-date">{{ transaction.date }}</span>
<span class="review-account">{{ transaction.account }}</span>
</div>
<div class="review-description">{{ transaction.description }}</div>
<div class="review-amount">${{ transaction.amount | money }}</div>
{% if bnpl_lender and bnpl_matches %}
<div style="background:#f0f5ff;border:1.5px solid #b0c4ef;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="font-weight:600;margin-bottom:0.5rem;">💳 Looks like a {{ bnpl_lender | title }} payment — which loan is this?</div>
<div style="display:flex;flex-direction:column;gap:0.5rem;">
{% for loan in bnpl_matches %}
<form method="POST" action="{{ url_for('match_bnpl') }}">
<input type="hidden" name="txn_id" value="{{ transaction.id }}">
<input type="hidden" name="debt_id" value="{{ loan.id }}">
<input type="hidden" name="category" value="Debt">
<button type="submit" style="width:100%;text-align:left;background:{% if loan.amount_match %}#e8f0fe{% else %}#fff{% endif %};border:1px solid {% if loan.amount_match %}#7aacf0{% else %}var(--border){% endif %};border-radius:6px;padding:0.6rem 0.85rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:1rem;">
<span>
<strong>{{ loan.name }}</strong>
<span style="font-size:0.78rem;color:var(--text-light);margin-left:0.5rem;">{{ loan.payments_made }}/{{ loan.total_payments }} payments made</span>
</span>
<span style="white-space:nowrap;">
${{ loan.payment_amount | money }}
{% if loan.amount_match %}<span style="color:#5A9E68;font-size:0.78rem;margin-left:0.4rem;">✓ amount matches</span>{% endif %}
</span>
</button>
</form>
{% endfor %}
</div>
<div style="margin-top:0.6rem;font-size:0.8rem;color:var(--text-light);">Selecting a loan will mark one payment as made and categorize this as Debt. Or ignore this and categorize manually below.</div>
</div>
{% elif bnpl_lender %}
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="font-weight:600;margin-bottom:0.25rem;">💳 Looks like a {{ bnpl_lender | title }} payment — no active loans found</div>
<div style="font-size:0.83rem;color:var(--text-light);margin-bottom:0.75rem;">Want to set up this loan now so future payments get tracked automatically?</div>
<button type="button" class="btn btn-ghost" style="font-size:0.85rem;" onclick="document.getElementById('bnpl-quick-add').style.display='block';this.style.display='none';">
+ Add {{ bnpl_lender | title }} loan now
</button>
<div id="bnpl-quick-add" style="display:none;margin-top:0.75rem;">
<form method="POST" action="{{ url_for('bnpl_add') }}">
<input type="hidden" name="bnpl_provider" value="{{ bnpl_lender | title }}">
<input type="hidden" name="redirect_to" value="review">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-bottom:0.75rem;">
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">What did you buy?</label>
<input type="text" name="description" class="budget-input" style="width:100%;" placeholder="e.g. Nike Shoes" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Merchant (optional)</label>
<input type="text" name="merchant" class="budget-input" style="width:100%;" placeholder="e.g. Nike">
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payment Amount</label>
<input type="number" name="payment_amount" step="0.01" min="0" class="budget-input" style="width:100%;" value="{{ transaction.amount }}" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Total # of Payments</label>
<input type="number" name="total_payments" min="1" class="budget-input" style="width:100%;" placeholder="e.g. 4" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Frequency</label>
<select name="frequency" class="budget-input" style="width:100%;">
<option value="biweekly">Every 2 weeks</option>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Next Payment Date</label>
<input type="date" name="next_payment_date" class="budget-input" style="width:100%;">
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Who's it for?</label>
<select name="owner" class="budget-input" style="width:100%;">
<option value="Shared">Shared</option>
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
</select>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payments already made</label>
<input type="number" name="payments_made" min="0" value="1" class="budget-input" style="width:100%;">
</div>
</div>
<div style="display:flex;gap:0.75rem;align-items:center;">
<button type="submit" class="btn btn-primary">Save Loan & Continue</button>
<button type="button" class="btn btn-ghost" onclick="document.getElementById('bnpl-quick-add').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
<span style="font-size:0.78rem;color:var(--text-light);">This transaction will still need to be categorized on the next screen</span>
</div>
</form>
</div>
</div>
{% endif %}
{% if suggestion %}
<div class="suggestion-banner">
<span>Suggested: <strong>{{ suggestion.category }}{% if suggestion.subcategory %} {{ suggestion.subcategory }}{% endif %}</strong></span>
<form method="POST" action="{{ url_for('categorize') }}" style="display:inline;">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="category" value="{{ suggestion.category }}">
<input type="hidden" name="subcategory" value="{{ suggestion.subcategory or '' }}">
<button type="submit" class="btn btn-accept">Accept</button>
</form>
</div>
{% endif %}
<form method="POST" action="{{ url_for('categorize') }}" class="categorize-form">
<input type="hidden" name="id" value="{{ transaction.id }}">
<div class="form-group">
<label>Category</label>
<div class="category-grid">
{% for cat, subcats in categories.items() %}
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
<input type="radio" name="category" value="{{ cat }}"
{% if suggestion and suggestion.category == cat %}checked{% endif %}
onchange="updateSubcats(this.value)">
<span>{{ cat }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="form-group" id="subcat-group">
<label>Subcategory <span class="optional">(optional)</span></label>
<select name="subcategory" id="subcat-select">
<option value="">— none —</option>
</select>
</div>
<!-- Sinking fund picker — shown when Transfer > Sinking Fund is selected -->
<div class="form-group" id="sinking-fund-prompt" style="display:none;">
<label>Which fund? <span class="optional">(will auto-add to that fund's balance)</span></label>
<select name="sinking_fund_name" id="sinking-fund-select" class="budget-input">
<option value="">— select fund —</option>
{% for f in sinking_funds %}
<option value="{{ f.name }}">{{ f.name }} (current: ${{ (f.balance or 0) | money }})</option>
{% endfor %}
</select>
</div>
<!-- Car prompt — shown when Gas subcategory is selected -->
<div class="form-group" id="car-prompt" style="display:none;">
<label>Which car? <span class="optional">(will auto-log to that car's profile)</span></label>
<select name="car_id" id="car-select">
<option value="">— select car —</option>
{% for car in personal_cars %}
<option value="{{ car.id }}">{{ car.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="e.g. Costco run, birthday gift...">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="remember" checked>
Remember this for future transactions from this merchant
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save & Next</button>
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
</div>
</form>
<!-- Quick-action buttons — must be outside the main form -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.75rem;">
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;"
title="Marks as reviewed but excludes from all totals — use for bill payments, debt payments, income deposits">
✓ Already tracked — ignore
</button>
</form>
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
<div id="review-sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;">
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="redirect" value="review">
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
<option value="">— select fund —</option>
{% for f in sinking_funds %}
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save & Next</button>
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
</div>
</form>
</div>
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="label" value="Investment Transfer">
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
</form>
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="label" value="Savings Transfer">
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
</form>
<form method="POST" action="{{ url_for('skip') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<button type="submit" class="btn btn-ghost" style="font-size:0.82rem;">Skip for now</button>
</form>
</div>
<!-- Split transaction form -->
<div id="split-panel" style="display:none;margin-top:1.5rem;">
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
</div>
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
<form method="POST" action="{{ url_for('split_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<table class="budget-table" id="split-table">
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
<tbody id="split-rows">
<tr class="split-row">
<td>
<select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this, 0)" style="width:130px;">
<option value="">— pick —</option>
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
</select>
</td>
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td></td>
</tr>
<tr class="split-row">
<td>
<select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this, 1)" style="width:130px;">
<option value="">— pick —</option>
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
</select>
</td>
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td></td>
</tr>
</tbody>
</table>
<div style="margin:0.75rem 0;font-size:0.85rem;">
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
</div>
<div style="display:flex;gap:0.75rem;">
<button type="submit" class="btn btn-primary">Save Split & Next</button>
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<script>
const subcats = {{ categories | tojson }};
const suggestion = {{ suggestion | tojson if suggestion else 'null' }};
function updateSubcats(cat) {
const select = document.getElementById('subcat-select');
const group = document.getElementById('subcat-group');
const list = subcats[cat] || [];
select.innerHTML = '<option value="">— none —</option>';
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
if (suggestion && suggestion.subcategory === s) opt.selected = true;
select.appendChild(opt);
});
group.style.display = list.length ? 'block' : 'none';
checkCarPrompt(cat, select.value);
}
function checkCarPrompt(cat, subcat) {
const carPrompt = document.getElementById('car-prompt');
if (cat === 'Car' && subcat === 'Gas') {
carPrompt.style.display = 'block';
} else {
carPrompt.style.display = 'none';
document.getElementById('car-select').value = '';
}
const sinkingPrompt = document.getElementById('sinking-fund-prompt');
if (cat === 'Transfer' && subcat === 'Sinking Fund') {
sinkingPrompt.style.display = 'block';
} else {
sinkingPrompt.style.display = 'none';
document.getElementById('sinking-fund-select').value = '';
}
}
document.getElementById('subcat-select').addEventListener('change', function() {
const cat = document.querySelector('input[name="category"]:checked');
checkCarPrompt(cat ? cat.value : '', this.value);
});
// Initialize on load
const checked = document.querySelector('input[name="category"]:checked');
if (checked) updateSubcats(checked.value);
document.querySelectorAll('input[name="category"]').forEach(r => {
r.addEventListener('change', () => updateSubcats(r.value));
});
// Split transaction
const totalAmount = {{ transaction.amount }};
const allSubcats = {{ categories | tojson }};
let splitRowCount = 2;
function toggleSplit() {
const panel = document.getElementById('split-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
function updateSplitSubcat(selectEl, idx) {
const cat = selectEl.value;
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
const list = allSubcats[cat] || [];
subcatSel.innerHTML = '<option value="">— none —</option>';
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s; opt.textContent = s;
subcatSel.appendChild(opt);
});
}
function updateRemaining() {
const inputs = document.querySelectorAll('.split-amt');
let used = 0;
inputs.forEach(el => used += parseFloat(el.value) || 0);
const remaining = Math.round((totalAmount - used) * 100) / 100;
const el = document.getElementById('split-remaining');
el.textContent = '$' + remaining.toFixed(2);
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
}
function addSplitRow() {
const idx = splitRowCount++;
const tbody = document.getElementById('split-rows');
const row = document.createElement('tr');
row.className = 'split-row';
row.innerHTML = `
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
<option value="">— pick —</option>
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
</select></td>
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
`;
tbody.appendChild(row);
}
</script>
{% endif %}
{% endblock %}

83
templates/settings.html Normal file
View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block content %}
<div style="max-width:640px;">
<h1>⚙️ Settings</h1>
<p style="color:#7A6E68;margin-bottom:1.25rem;">
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.
</p>
{% if saved %}
<div class="alert-banner" style="background:#E7F3E9;border-color:#BFE0C5;margin-bottom:1rem;">
<span>✓ Settings saved.</span>
</div>
{% endif %}
<form method="POST" action="{{ url_for('settings_view') }}">
<fieldset style="border:1px solid #EDE8E2;border-radius:10px;padding:1rem 1.25rem;margin-bottom:1.25rem;">
<legend style="padding:0 .5rem;font-weight:600;">📧 Email reminders (Gmail)</legend>
<label style="display:block;margin-bottom:.9rem;">
<span style="display:block;font-size:.85rem;color:#7A6E68;margin-bottom:.25rem;">Gmail address</span>
<input type="email" name="gmail_address" value="{{ settings.gmail_address }}"
placeholder="you@gmail.com" style="width:100%;padding:.55rem;border:1px solid #cfcfd4;border-radius:8px;">
</label>
<label style="display:block;margin-bottom:.9rem;">
<span style="display:block;font-size:.85rem;color:#7A6E68;margin-bottom:.25rem;">
Gmail app password
{% if secrets_status.gmail_app_password %}
<strong style="color:#5A9E68;">· set ✓</strong>
{% else %}
<strong style="color:#C57A6E;">· not set</strong>
{% endif %}
</span>
<input type="password" name="gmail_app_password" autocomplete="new-password"
placeholder="{% if secrets_status.gmail_app_password %}•••••••••••• (leave blank to keep){% else %}16-character app password{% endif %}"
style="width:100%;padding:.55rem;border:1px solid #cfcfd4;border-radius:8px;">
<span style="display:block;font-size:.78rem;color:#B0A89E;margin-top:.25rem;">
Not your normal password — create an
<a href="https://myaccount.google.com/apppasswords" target="_blank" rel="noopener">app password</a> in your Google account.
</span>
</label>
<label style="display:block;margin-bottom:.9rem;">
<span style="display:block;font-size:.85rem;color:#7A6E68;margin-bottom:.25rem;">Send reminders to (optional)</span>
<input type="email" name="reminder_recipient" value="{{ settings.reminder_recipient }}"
placeholder="defaults to the Gmail address above" style="width:100%;padding:.55rem;border:1px solid #cfcfd4;border-radius:8px;">
</label>
<label style="display:flex;align-items:center;gap:.5rem;">
<input type="checkbox" name="reminders_enabled" {% if settings.reminders_enabled %}checked{% endif %}>
<span>Email reminders enabled</span>
</label>
</fieldset>
<button type="submit" class="btn btn-primary">Save settings</button>
</form>
<fieldset style="border:1px solid #EDE8E2;border-radius:10px;padding:1rem 1.25rem;margin-top:1.5rem;">
<legend style="padding:0 .5rem;font-weight:600;">🔑 Passkeys (sign-in)</legend>
<p style="font-size:.85rem;color:#7A6E68;margin-top:0;">
Login is currently <strong>{{ 'ON' if auth_enabled else 'OFF' }}</strong>.
Devices enrolled with a passkey (Touch ID / Face ID / security key):
</p>
{% if passkeys %}
<ul style="list-style:none;padding:0;margin:0 0 .75rem;">
{% for d in passkeys %}
<li style="display:flex;align-items:center;justify-content:space-between;border:1px solid #EDE8E2;border-radius:8px;padding:.5rem .75rem;margin-bottom:.4rem;">
<span>🔑 {{ d.label }}</span>
<form method="POST" action="{{ url_for('auth.device_remove') }}" onsubmit="return confirm('Remove this passkey?');" style="margin:0;">
<input type="hidden" name="id" value="{{ d.id }}">
<button type="submit" style="background:none;border:0;color:#C57A6E;cursor:pointer;font-size:.8rem;">Remove</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p style="font-size:.85rem;color:#B0A89E;">No devices enrolled yet.</p>
{% endif %}
<a href="{{ url_for('auth.enroll') }}" class="btn btn-primary" style="display:inline-block;text-decoration:none;">+ Add a device</a>
</fieldset>
</div>
{% endblock %}

175
templates/sinking.html Normal file
View File

@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Sinking Funds</h1>
</div>
<!-- Total savings banner -->
<div class="sinking-total-bar">
<div>
<div class="sinking-total-label">Total Across All Funds</div>
<div class="sinking-total-amount">${{ total | money }}</div>
</div>
<div class="sinking-total-note">Match this number to your savings account balance</div>
</div>
<!-- Fund cards -->
<div class="sinking-grid">
{% for f in funds %}
{% set color = fund_colors.get(f.name, '#C5BBA0') %}
<div class="sinking-card" style="border-top: 4px solid {{ color }};">
<div class="sinking-card-header">
<div class="sinking-card-name">{{ f.name }}</div>
<span class="sinking-type-badge {% if f.type == 'goal' %}badge-goal{% endif %}">
{{ 'Goal' if f.type == 'goal' else 'Ongoing' }}
</span>
</div>
{% if f.notes %}
<div class="sinking-notes">{{ f.notes }}</div>
{% endif %}
<div class="sinking-balance">${{ (f.balance or 0) | money }}</div>
{% if f.type == 'goal' and f.goal %}
{% set pct = f.pct or 0 %}
<div class="sinking-progress-wrap">
<div class="sinking-progress-bar">
<div class="sinking-progress-fill" style="width:{{ pct }}%;background:{{ color }};"></div>
</div>
<div class="sinking-progress-label">
${{ (f.balance or 0) | money }} of ${{ f.goal | money }} ({{ pct }}%)
</div>
</div>
{% if f.monthly_needed %}
<div class="sinking-monthly-needed">
Save <strong>${{ f.monthly_needed | money }}/mo</strong>
to reach goal
{% if f.months_left %} in {{ f.months_left }} months{% endif %}
</div>
{% endif %}
{% else %}
{% if f.monthly %}
<div class="sinking-monthly-needed">
Monthly contribution: <strong>${{ f.monthly | money }}</strong>
</div>
{% endif %}
{% endif %}
<!-- Contribute / Withdraw -->
<div class="sinking-actions">
<form method="POST" action="{{ url_for('sinking_contribute') }}" class="sinking-action-form">
<input type="hidden" name="name" value="{{ f.name }}">
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" class="sinking-amount-input">
<button type="submit" class="btn btn-accept">+ Add</button>
</form>
<form method="POST" action="{{ url_for('sinking_withdraw') }}" class="sinking-action-form">
<input type="hidden" name="name" value="{{ f.name }}">
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" class="sinking-amount-input">
<button type="submit" class="btn btn-ghost"> Use</button>
</form>
</div>
<!-- Edit settings -->
<details class="sinking-edit-details">
<summary class="sinking-edit-toggle">Edit settings</summary>
<div class="sinking-edit-form">
<div class="form-group">
<label>Correct Balance <span class="optional">(override if something's off)</span></label>
<div style="display:flex;gap:0.5rem;align-items:center;">
<input type="number" step="0.01" id="balance-override-{{ loop.index }}" placeholder="{{ '%.2f' | format(f.balance or 0) }}" style="flex:1;">
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;white-space:nowrap;"
onclick="setBalance('{{ f.name }}', document.getElementById('balance-override-{{ loop.index }}').value)">Set</button>
</div>
</div>
<div class="form-group">
<label>Type</label>
<select onchange="updateFund('{{ f.name }}', 'type', this.value)">
<option value="ongoing" {% if f.type != 'goal' %}selected{% endif %}>Ongoing</option>
<option value="goal" {% if f.type == 'goal' %}selected{% endif %}>Goal</option>
</select>
</div>
<div class="form-group">
<label>Monthly Contribution</label>
<input type="number" step="0.01" value="{{ f.monthly or '' }}" placeholder="0.00"
onchange="updateFund('{{ f.name }}', 'monthly', this.value)">
</div>
<div class="form-group">
<label>Goal Amount <span class="optional">(if goal type)</span></label>
<input type="number" step="0.01" value="{{ f.goal or '' }}" placeholder="0.00"
onchange="updateFund('{{ f.name }}', 'goal', this.value)">
</div>
<div class="form-group">
<label>Target Date <span class="optional">(if goal type)</span></label>
<input type="date" value="{{ f.target_date or '' }}"
onchange="updateFund('{{ f.name }}', 'target_date', this.value)">
</div>
<div class="form-group">
<label>Notes</label>
<input type="text" value="{{ f.notes or '' }}" placeholder="Optional note..."
onchange="updateFund('{{ f.name }}', 'notes', this.value)">
</div>
</div>
</details>
</div>
{% endfor %}
<!-- Add new fund card -->
<div class="sinking-card sinking-add-card">
<div class="sinking-card-name" style="margin-bottom:1rem;">+ New Fund</div>
<form method="POST" action="{{ url_for('sinking_add') }}">
<div class="form-group">
<label>Fund Name</label>
<input type="text" name="name" placeholder="e.g. New Car, Vacation" required>
</div>
<div class="form-group">
<label>Type</label>
<select name="type">
<option value="ongoing">Ongoing</option>
<option value="goal">Goal</option>
</select>
</div>
<div class="form-group">
<label>Starting Balance <span class="optional">(optional)</span></label>
<input type="number" name="balance" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Monthly Contribution</label>
<input type="number" name="monthly" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Goal Amount <span class="optional">(if goal)</span></label>
<input type="number" name="goal" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label>Target Date <span class="optional">(if goal)</span></label>
<input type="date" name="target_date">
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="">
</div>
<button type="submit" class="btn btn-primary">Add Fund</button>
</form>
</div>
</div>
<script>
function updateFund(name, field, value) {
fetch('/sinking/update', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({name, field, value})
});
}
function setBalance(name, value) {
if (value === '' || isNaN(parseFloat(value))) return;
fetch('/sinking/update', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({name, field: 'balance', value})
}).then(() => location.reload());
}
</script>
{% endblock %}

116
templates/strategy.html Normal file
View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Debt Payoff Strategy</h1>
<a href="{{ url_for('debts_view') }}" class="btn btn-ghost">← Back to Debts</a>
</div>
<div class="card-wide" style="margin-bottom:1.5rem;">
<h2>How much can you put toward debt each month?</h2>
<p class="helper-text">Your current total minimums are <strong>${{ total_minimums | money }}</strong>. Enter your total monthly debt payment below — anything above the minimums gets applied strategically.</p>
<form method="POST" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap;margin-top:1rem;">
<div class="form-group" style="margin:0;">
<label>Total Monthly Payment</label>
<input type="number" name="extra_monthly" step="0.01" min="{{ total_minimums }}"
value="{{ total_monthly or total_minimums }}" placeholder="{{ total_minimums }}">
</div>
<button type="submit" class="btn btn-primary">Calculate</button>
</form>
</div>
{% if av_months %}
<!-- Summary comparison -->
<div class="strategy-compare">
<div class="strategy-card strategy-avalanche">
<div class="strategy-label">Avalanche Method</div>
<div class="strategy-subtitle">Highest interest rate first</div>
<div class="strategy-stat">
<span>Debt-free in</span>
<strong>{{ av_months }} months</strong>
</div>
<div class="strategy-stat">
<span>Total interest paid</span>
<strong>${{ av_interest | money }}</strong>
</div>
<div class="strategy-badge">Saves the most money</div>
</div>
<div class="strategy-vs">VS</div>
<div class="strategy-card strategy-snowball">
<div class="strategy-label">Snowball Method</div>
<div class="strategy-subtitle">Smallest balance first</div>
<div class="strategy-stat">
<span>Debt-free in</span>
<strong>{{ sb_months }} months</strong>
</div>
<div class="strategy-stat">
<span>Total interest paid</span>
<strong>${{ sb_interest | money }}</strong>
</div>
<div class="strategy-badge">Faster wins, more motivation</div>
</div>
</div>
{% if interest_saved > 0 or months_diff != 0 %}
<div class="strategy-insight">
{% if interest_saved > 0 %}
<span>The Avalanche method saves you <strong>${{ interest_saved | money }}</strong> in interest</span>
{% endif %}
{% if months_diff > 0 %}
<span>The Snowball method takes <strong>{{ months_diff }} month{{ 's' if months_diff != 1 }}</strong> longer</span>
{% elif months_diff < 0 %}
<span>The Avalanche method takes <strong>{{ months_diff|abs }} month{{ 's' if months_diff|abs != 1 }}</strong> longer</span>
{% endif %}
</div>
{% endif %}
<!-- Side by side payoff order -->
<div class="strategy-tables">
<div class="strategy-table-wrap">
<h3>Avalanche Payoff Order</h3>
<table class="txn-table">
<thead>
<tr><th>#</th><th>Debt</th><th>Owner</th><th>Paid Off</th><th>Interest Paid</th></tr>
</thead>
<tbody>
{% for d in avalanche | sort(attribute='paid_off_month') %}
<tr>
<td class="muted">{{ loop.index }}</td>
<td>{{ d.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span></td>
<td>{{ d.paid_off_month or '—' }}</td>
<td class="amount">${{ d.total_interest | money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="strategy-table-wrap">
<h3>Snowball Payoff Order</h3>
<table class="txn-table">
<thead>
<tr><th>#</th><th>Debt</th><th>Owner</th><th>Paid Off</th><th>Interest Paid</th></tr>
</thead>
<tbody>
{% for d in snowball | sort(attribute='paid_off_month') %}
<tr>
<td class="muted">{{ loop.index }}</td>
<td>{{ d.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner, '#ddd') }};">{{ d.owner }}</span></td>
<td>{{ d.paid_off_month or '—' }}</td>
<td class="amount">${{ d.total_interest | money }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state">
<p>Add your debts and monthly payment amount above to see your payoff strategy.</p>
</div>
{% endif %}
{% endblock %}

71
templates/summary.html Normal file
View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<div class="page-header">
<h1>Spending Report</h1>
</div>
{% if by_category %}
<!-- Horizontal bar chart -->
<div class="card-wide" style="margin-bottom:1.5rem;">
<canvas id="spending-bar-chart" height="80"></canvas>
</div>
<div class="summary-grid">
{% for cat, data in by_category | dictsort %}
<div class="summary-card" style="border-top: 4px solid {{ colors.get(cat, '#ddd') }};">
<div class="summary-cat-name">{{ cat }}</div>
<div class="summary-cat-total">${{ data.total | money }}</div>
{% if data.subcategories %}
<ul class="subcat-list">
{% for sub, amt in data.subcategories | dictsort %}
<li>
<span>{{ sub }}</span>
<span>${{ amt | money }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No categorized transactions yet. <a href="{{ url_for('import_transactions') }}">Import a statement</a> to get started.</p>
</div>
{% endif %}
<script>
{% if by_category %}
const spendingLabels = {{ by_category.keys() | list | tojson }};
const spendingValues = {{ by_category.values() | map(attribute='total') | list | tojson }};
const spendingColors = [{% for cat in by_category %}'{{ colors.get(cat, "#ddd") }}',{% endfor %}];
new Chart(document.getElementById('spending-bar-chart'), {
type: 'bar',
data: {
labels: spendingLabels,
datasets: [{
data: spendingValues,
backgroundColor: spendingColors,
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.x.toFixed(2) } }
},
scales: {
x: { grid: { color: '#f0ece6' }, ticks: { callback: v => '$' + v.toLocaleString() } },
y: { grid: { display: false } }
},
animation: { duration: 400 },
}
});
{% endif %}
</script>
{% endblock %}

187
templates/taxes.html Normal file
View File

@@ -0,0 +1,187 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>{{ year }} Tax Estimator</h1>
<span style="font-size:0.85rem;color:var(--text-light);">Mixed income household (self-employment + W-2) — Wisconsin, MFJ</span>
</div>
<!-- Hero numbers -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem;">
<div class="card-wide" style="text-align:center;padding:1.25rem;">
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">YTD Income</div>
<div style="font-size:2rem;font-weight:700;color:var(--text-dark);">${{ ytd_total | money }}</div>
</div>
<div class="card-wide" style="text-align:center;padding:1.25rem;">
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Estimated Tax Owed</div>
<div style="font-size:2rem;font-weight:700;color:#c07070;">${{ estimate.total | money }}</div>
<div style="font-size:0.78rem;color:var(--text-light);">~{{ estimate.effective_rate }}% effective rate</div>
</div>
<div class="card-wide" style="text-align:center;padding:1.25rem;">
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Set Aside So Far</div>
<div style="font-size:2rem;font-weight:700;color:#5A9E68;">${{ total_set_aside | money }}</div>
</div>
<div class="card-wide" style="text-align:center;padding:1.25rem;">
{% if over_saved > 0 %}
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Over-Saved</div>
<div style="font-size:2rem;font-weight:700;color:#5A9E68;">${{ over_saved | money }}</div>
<div style="font-size:0.78rem;color:var(--text-light);">nice work!</div>
{% else %}
<div style="font-size:0.8rem;color:var(--text-light);margin-bottom:0.25rem;">Still Need to Set Aside</div>
<div style="font-size:2rem;font-weight:700;color:{% if still_needed > 500 %}#c07070{% else %}#d4a843{% endif %};">${{ still_needed | money }}</div>
{% endif %}
</div>
</div>
<!-- Progress bar -->
{% if estimate.total > 0 %}
{% set pct = [(total_set_aside / estimate.total * 100), 100] | min | int %}
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.5rem;">
<div style="display:flex;justify-content:space-between;font-size:0.85rem;margin-bottom:0.5rem;">
<span>Tax savings progress</span>
<span style="font-weight:600;">{{ pct }}% saved</span>
</div>
<div style="background:#EDE8E2;border-radius:6px;height:12px;">
<div style="background:{% if pct >= 100 %}#5A9E68{% elif pct >= 60 %}#d4a843{% else %}#c07070{% endif %};border-radius:6px;height:12px;width:{{ pct }}%;transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
<!-- Breakdown -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem;margin-bottom:1.5rem;">
<!-- Tax breakdown -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#c07070;">
<h3>How We Got There</h3>
</div>
<table class="budget-table">
<tbody>
<tr style="background:var(--cream);">
<td colspan="2" style="font-size:0.78rem;color:var(--text-mid);padding:0.4rem 0.5rem;">
<strong>Self-employment income</strong> (Bonna + Car Flipping): ${{ ytd_se | money }} &nbsp;·&nbsp;
<strong>W-2 income</strong> (Tony / Auto King): ${{ ytd_w2 | money }}
</td>
</tr>
<tr>
<td>Self-Employment Tax <span class="muted" style="font-size:0.78rem;">(SS + Medicare on SE income only)</span></td>
<td class="amount">${{ estimate.se_tax | money }}</td>
</tr>
<tr>
<td>Federal Income Tax <span class="muted" style="font-size:0.78rem;">(~{{ rates.federal }}% after $30k MFJ deduction)</span></td>
<td class="amount">${{ estimate.federal | money }}</td>
</tr>
<tr>
<td>Wisconsin State Tax <span class="muted" style="font-size:0.78rem;">(~{{ rates.wi_state }}% after $30k MFJ deduction)</span></td>
<td class="amount">${{ estimate.wi_state | money }}</td>
</tr>
</tbody>
<tfoot>
<tr class="total-row">
<td>Total Estimated</td>
<td class="amount">${{ estimate.total | money }}</td>
</tr>
</tfoot>
</table>
<details style="margin-top:0.75rem;">
<summary style="font-size:0.8rem;color:var(--text-light);cursor:pointer;">Adjust tax rates</summary>
<form method="POST" action="{{ url_for('taxes_rates') }}" style="margin-top:0.75rem;display:grid;gap:0.5rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<label style="font-size:0.82rem;width:160px;">SE Tax %</label>
<input type="number" name="se_tax" step="0.1" value="{{ rates.se_tax }}" class="budget-input no-dollar" style="width:80px;">
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label style="font-size:0.82rem;width:160px;">Federal %</label>
<input type="number" name="federal" step="0.1" value="{{ rates.federal }}" class="budget-input no-dollar" style="width:80px;">
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label style="font-size:0.82rem;width:160px;">WI State %</label>
<input type="number" name="wi_state" step="0.1" value="{{ rates.wi_state }}" class="budget-input no-dollar" style="width:80px;">
</div>
<button type="submit" class="btn btn-ghost" style="width:fit-content;font-size:0.82rem;">Update Rates</button>
</form>
</details>
</div>
<!-- Quarterly breakdown -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Quarterly Estimates</h3>
</div>
<table class="budget-table">
<thead><tr><th>Quarter</th><th>Income</th><th>Est. Tax</th><th>Due</th></tr></thead>
<tbody>
{% for q in quarters %}
<tr{% if q.income == 0 %} style="opacity:0.5;"{% endif %}>
<td style="font-size:0.85rem;">{{ q.label }}</td>
<td class="amount">${{ q.income | money }}</td>
<td class="amount">${{ q.estimate.total | money }}</td>
<td style="font-size:0.78rem;color:var(--text-light);">{{ q.due_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="font-size:0.75rem;color:var(--text-light);margin-top:0.5rem;padding:0 0.25rem;">These are federal estimated payment deadlines. WI follows the same schedule.</p>
</div>
</div>
<!-- Set aside log -->
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#5A9E68;">
<h3>Tax Savings Log</h3>
</div>
<details class="add-debt-details" style="margin-bottom:1rem;">
<summary class="btn btn-primary" style="display:inline-block;cursor:pointer;">+ Log Money Set Aside</summary>
<div style="padding:1rem 0;">
<form method="POST" action="{{ url_for('taxes_set_aside') }}" style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Date</label>
<input type="date" name="date" value="{{ today.strftime('%Y-%m-%d') }}" class="budget-input" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Amount</label>
<input type="number" name="amount" step="0.01" min="0" class="budget-input" style="width:130px;" placeholder="0.00" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Notes</label>
<input type="text" name="notes" class="budget-input" style="width:200px;" placeholder="e.g. moved to savings account">
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</details>
{% if set_aside_log %}
<table class="budget-table">
<thead><tr><th>Date</th><th>Amount</th><th>Notes</th><th></th></tr></thead>
<tbody>
{% for e in set_aside_log | sort(attribute='date', reverse=True) %}
<tr>
<td>{{ e.date }}</td>
<td class="amount" style="color:#5A9E68;">${{ e.amount | money }}</td>
<td class="muted">{{ e.notes }}</td>
<td>
<form method="POST" action="{{ url_for('taxes_set_aside_delete', entry_id=e.id) }}">
<button type="submit" class="edit-link" style="color:#c07070;background:none;border:none;cursor:pointer;">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total Set Aside</td>
<td class="amount" style="color:#5A9E68;">${{ total_set_aside | money }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.5rem 0;">Nothing logged yet. Every time you move money to your tax savings account, record it here.</p>
{% endif %}
</div>
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;font-size:0.83rem;color:var(--text-mid);margin-top:1rem;">
<strong>Heads up:</strong> 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.
</div>
{% endblock %}

168
templates/transactions.html Normal file
View File

@@ -0,0 +1,168 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>All Transactions</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert-banner {{ 'alert-success' if category == 'success' else 'alert-error' }}" style="margin-bottom:1rem;">
{{ message }}
{% if category == 'success' %}
<a href="{{ url_for('review') }}" class="btn btn-primary">Review Now</a>
{% endif %}
</div>
{% endfor %}
{% endwith %}
<!-- Import -->
<details class="add-debt-details" style="margin-bottom:1.25rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
<div class="card-wide" style="margin-top:0.75rem;">
<p class="helper-text" style="margin-bottom:0.75rem;">Supports CSV exports from your bank or credit union. PDF import only works if the PDF has selectable text — scanned statements won't work.</p>
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data" class="import-form">
<div class="form-group">
<label>Account Name</label>
<input type="text" name="account" placeholder="e.g. WestConsin Checking, Discover" required>
</div>
<div class="form-group">
<label>Statement File</label>
<div class="file-drop-area">
<input type="file" name="statement" accept=".pdf,.csv" required>
<p>Click to choose a file, or drag and drop here</p>
<p class="file-types">PDF or CSV</p>
</div>
</div>
<button type="submit" class="btn btn-primary">Import</button>
</form>
<details style="margin-top:1rem;">
<summary class="muted" style="cursor:pointer;font-size:0.85rem;">How to export your statements ▾</summary>
<ul style="margin-top:0.5rem;font-size:0.83rem;line-height:1.7;padding-left:1.25rem;">
<li><strong>WestConsin Credit Union:</strong> Log in → select your account → Download/Export → choose CSV → pick date range</li>
<li><strong>Most other banks:</strong> Log in → Account Details or Transaction History → Export / Download → choose CSV</li>
<li><strong>Affirm:</strong> affirm.com → Account → Payment History → Export</li>
<li><strong>Klarna:</strong> klarna.com → Purchases → Export CSV</li>
<li><strong>Afterpay:</strong> afterpay.com → Orders → Export</li>
</ul>
<p class="helper-text" style="margin-top:0.5rem;">If a CSV imports 0 transactions, let Claude know — the column format varies by bank and may need a quick fix.</p>
</details>
</div>
</details>
{% if spending_by_cat %}
<details class="add-debt-details" style="margin-bottom:1.25rem;">
<summary class="btn btn-ghost" style="display:inline-block;cursor:pointer;">📊 {{ week_label }} Spending</summary>
<div style="margin-top:0.75rem;background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;">
<table class="budget-table">
<thead>
<tr><th>Category</th><th>Spent</th><th>Budgeted</th><th>Remaining</th><th style="width:25%;"></th></tr>
</thead>
<tbody>
{% 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 %}
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
<td><span class="tag" style="background:{{ colors.get(cat,'#ddd') }};">{{ cat }}</span></td>
<td class="amount">${{ amt | money }}</td>
<td class="amount muted">{% if budgeted %}${{ budgeted | money }}{% else %}<span style="color:#ccc;"></span>{% endif %}</td>
<td class="amount {% if over %}{% else %}{% endif %}">
{% if budgeted %}
{% if over %}
<span style="color:#c07070;">-${{ (amt - budgeted) | money }} over</span>
{% else %}
<span style="color:#5A9E68;">${{ remaining | money }}</span>
{% endif %}
{% else %}
<span class="muted"></span>
{% endif %}
</td>
<td>
<div style="background:#EDE8E2;border-radius:4px;height:7px;">
<div style="background:{{ '#c07070' if over else colors.get(cat,'#bbb') }};border-radius:4px;height:7px;width:{{ pct }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount">${{ spending_by_cat.values() | sum | money }}</td>
<td class="amount muted">{% if budgeted_by_cat %}${{ budgeted_by_cat.values() | sum | money }}{% endif %}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</details>
{% endif %}
<form method="GET" style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1.25rem;background:var(--warm-white);padding:0.85rem 1rem;border-radius:var(--radius);border:1px solid var(--border);">
<div style="flex:1;min-width:180px;">
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">Search</label>
<input type="text" name="search" value="{{ filter_search }}" class="budget-input" style="width:100%;" placeholder="merchant, notes…">
</div>
<div>
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">From</label>
<input type="date" name="from_date" value="{{ filter_from }}" class="budget-input">
</div>
<div>
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">To</label>
<input type="date" name="to_date" value="{{ filter_to }}" class="budget-input">
</div>
<div>
<label style="display:block;font-size:0.8rem;margin-bottom:0.2rem;">Category</label>
<select name="category" class="budget-input">
<option value="">— all —</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if filter_cat == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary" style="height:36px;">Search</button>
{% if filter_from or filter_to or filter_cat or filter_search %}
<a href="{{ url_for('transactions_view') }}" class="btn btn-ghost" style="height:36px;line-height:36px;padding-top:0;padding-bottom:0;">Clear</a>
{% endif %}
</form>
{% if transactions %}
<table class="txn-table txn-table-full">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Account</th>
<th>Amount</th>
<th>Category</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in transactions %}
<tr>
<td>{{ t.date }}</td>
<td>{{ t.description }}</td>
<td class="muted">{{ t.account }}</td>
<td class="amount">${{ t.amount | money }}</td>
<td>
{% if t.category %}
<span class="tag" style="background: {{ colors.get(t.category, '#ddd') }};">
{{ t.category }}{% if t.subcategory %} {{ t.subcategory }}{% endif %}
</span>
{% endif %}
</td>
<td class="muted">{{ t.notes }}</td>
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>No transactions yet. <a href="{{ url_for('import_transactions') }}">Import a statement</a> to get started.</p>
</div>
{% endif %}
{% endblock %}

484
templates/weekly.html Normal file
View File

@@ -0,0 +1,484 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>{{ month_name }} {{ year }} — Weekly Overview</h1>
<div class="month-nav">
<a href="{{ url_for('weekly_overview', year=prev_year, month=prev_month) }}" class="btn btn-ghost"> Prev</a>
<a href="{{ url_for('weekly_overview', year=next_year, month=next_month) }}" class="btn btn-ghost">Next </a>
</div>
</div>
<div class="week-tabs">
{% for w in weeks %}
<button class="week-tab" onclick="showWeek({{ w.num }})" id="tab-{{ w.num }}">
Week {{ w.num }}<br><span>{{ w.start_label }} {{ w.end_label }}</span>
</button>
{% endfor %}
</div>
{% for w in weeks %}
<div class="week-panel" id="week-{{ w.num }}">
<!-- Week summary -->
{% if w.summary %}
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.1rem;margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.5rem 1.5rem;">
<span style="font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);width:100%;">How's this week looking?</span>
{% for line in w.summary %}
<span style="font-size:0.88rem;color:var(--text-dark);">{{ line | bold_md }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Starting Balance -->
<div style="display:flex;align-items:center;gap:1rem;background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1rem;margin-bottom:1rem;">
<label style="font-weight:600;white-space:nowrap;">Checking Balance at Start of Week</label>
<input type="number" step="0.01" class="budget-input" style="max-width:160px;"
id="starting-balance-{{ w.num }}"
value="{{ w.starting_balance }}"
onchange="saveStartingBalance({{ year }}, {{ month }}, '{{ w.num }}', this.value)"
placeholder="0.00">
<span style="font-size:0.82rem;color:var(--text-light);">Enter what's in checking when the week begins</span>
</div>
<!-- 1. Income -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Income', '#A0C5A8') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Income</h3>
</div>
<div class="weekly-section-body">
<table class="budget-table">
<thead><tr><th>Source</th><th>Budgeted</th><th>Actual</th></tr></thead>
<tbody>
{% for src in w.income_sources %}
{% set inc = w.income.get(src, {}) %}
<tr>
<td>
{{ src }}
{% if src == 'Auto King' %}
<span class="income-friday-tag">Fridays</span>
{% endif %}
</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ inc.get('budgeted', '') }}"
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'income', '{{ src }}', 'budgeted', this.value)"
placeholder="expected"></td>
<td>
<div class="income-actual-wrap">
<input type="number" step="0.01" class="budget-input income-actual-input"
id="income-actual-{{ w.num }}-{{ src | replace(' ','_') }}"
value="{{ inc.get('actual', '') }}"
onchange="logIncome({{ year }}, {{ month }}, '{{ w.num }}', '{{ src }}', this.value)"
placeholder="enter when received">
{% if inc.get('actual') %}
<span class="income-logged-badge">✓ logged</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount">$<span id="w{{ w.num }}-income-budget-total">{{ (w.income.values() | map(attribute='budgeted') | select | map('float') | sum if w.income else 0) | money }}</span></td>
<td class="amount">$<span id="w{{ w.num }}-income-actual-total">{{ w.income_total | money }}</span></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 2. Bills -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Bills Due This Week</h3>
<a href="{{ url_for('bills_list') }}" class="edit-link" onclick="event.stopPropagation()">manage bills</a>
</div>
<div class="weekly-section-body">
{% if w.bills %}
<table class="budget-table">
<thead><tr><th>Bill</th><th>Due</th><th>Amount</th><th>Category</th></tr></thead>
<tbody>
{% for b in w.bills %}
<tr>
<td>{{ b.name }}</td>
<td class="muted">{{ b.due_date.strftime('%b %-d') if b.due_date else '' }}</td>
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted"></span>{% endif %}</td>
<td>
{% if b.category %}
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">{{ b.category }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Bills Total</td>
<td class="amount">${{ w.bills_total | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding: 0.75rem 0;">No bills due this week.</p>
{% endif %}
</div>
</div>
<!-- 3. Budget Categories -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Food', '#A8C5A0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Budget Categories</h3>
</div>
<div class="weekly-section-body">
{% set skip = ['Income', 'Debt', 'Transfer'] %}
{% set total_budgeted = namespace(v=0) %}
{% set total_actual = namespace(v=0) %}
<table class="budget-table">
<thead><tr><th>Category</th><th>Budgeted</th><th>Spent</th><th>Left to Spend</th></tr></thead>
<tbody>
{% 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 %}
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
<td>
<span class="tag" style="background: {{ colors.get(cat, '#ddd') }};">{{ cat }}</span>
</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ cb.get('budgeted', '') }}"
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'categories', '{{ cat }}', 'budgeted', this.value)"
placeholder="0.00"></td>
<td class="amount{% if actual == 0 %} muted{% endif %}">
${{ actual | money }}
</td>
<td class="amount">
{% if budgeted %}
{% if over %}
<span style="color:#c07070;">-${{ (actual - budgeted) | money }}</span>
{% else %}
<span style="color:#5A9E68;">${{ left | money }}</span>
{% endif %}
{% else %}
<span class="muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
{% set total_left = total_budgeted.v - total_actual.v %}
<tr class="total-row">
<td>Totals</td>
<td class="amount">${{ total_budgeted.v | money }}</td>
<td class="amount">${{ total_actual.v | money }}</td>
<td class="amount">
{% if total_budgeted.v > 0 %}
{% if total_left < 0 %}
<span style="color:#c07070;font-weight:700;">-${{ total_left | abs | money }} over</span>
{% else %}
<span style="color:#5A9E68;font-weight:700;">${{ total_left | money }} left</span>
{% endif %}
{% endif %}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 4. Sinking Funds -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Travel', '#C5BBA0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Sinking Funds</h3>
<a href="{{ url_for('sinking_view') }}" class="edit-link" onclick="event.stopPropagation()">manage funds</a>
</div>
<div class="weekly-section-body" style="display:none;">
<table class="budget-table">
<thead><tr><th>Fund</th><th>Current Balance</th><th>Add This Week</th></tr></thead>
<tbody>
{% for f in sinking_funds %}
{% set wkey = 'sinking_' ~ w.num ~ '_' ~ f.name %}
{% set sinking_entry = w.sinking.get(f.name, {}) %}
{% set contributed = sinking_entry.get('contributed', '') %}
{% set from_txn = sinking_entry.get('from_transaction') %}
<tr>
<td>
<span class="tag" style="background: {{ fund_colors.get(f.name, '#C5BBA0') }};">{{ f.name }}</span>
</td>
<td class="amount">${{ (f.balance or 0) | money }}</td>
<td>
<div class="income-actual-wrap">
<input type="number" step="0.01" class="budget-input"
value="{{ contributed }}"
onchange="contributeSinking('{{ f.name }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
placeholder="0.00"
{% if from_txn %}title="Logged automatically from a categorized transaction"{% endif %}>
{% if from_txn %}
<span class="income-logged-badge" title="This was recorded when you categorized a transaction">✓ from transaction</span>
{% elif contributed %}
<span class="income-logged-badge">✓ added</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total Contributed This Week</td>
<td class="amount">${{ (w.sinking.values() | selectattr('contributed') | map(attribute='contributed') | map('float') | sum) | money }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 5. Extra Debt Payments -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Extra Debt Payments</h3>
<a href="{{ url_for('debts_view') }}" class="edit-link" onclick="event.stopPropagation()">manage debts</a>
</div>
<div class="weekly-section-body" style="display:none;">
{% if active_debts %}
<table class="budget-table">
<thead><tr><th>Debt</th><th>Owner</th><th>Minimum</th><th>Extra Payment</th></tr></thead>
<tbody>
{% for d in active_debts %}
{% set extra_paid = w.extra_debt.get(d.id, {}).get('extra', '') %}
<tr>
<td>{{ d.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
<td class="amount muted">${{ (d.minimum or 0) | money }}</td>
<td>
<div class="income-actual-wrap">
<input type="number" step="0.01" class="budget-input"
value="{{ extra_paid }}"
onchange="logExtraDebt('{{ d.id }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
placeholder="0.00">
{% if extra_paid %}
<span class="income-logged-badge">✓ logged</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">Total Extra This Week</td>
<td class="amount accent">${{ (w.extra_debt.values() | map(attribute='extra') | select | map('float') | sum) | money }}</td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No debts added yet. <a href="{{ url_for('debts_view') }}">Add debts</a> to track extra payments here.</p>
{% endif %}
</div>
</div>
<!-- 6. Transactions -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color:#D4A96A;cursor:pointer;">
<h3><span class="weekly-arrow"></span> Transactions This Week{% if w.transactions %} <span style="font-weight:400;font-size:0.82rem;color:var(--text-light);">({{ w.transactions | length }})</span>{% endif %}</h3>
</div>
<div class="weekly-section-body" style="display:none;">
<!-- Inline import -->
<details style="margin-bottom:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
<div style="margin-top:0.75rem;background:var(--cream);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;">
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data">
<input type="hidden" name="redirect_to" value="weekly">
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Account Name</label>
<input type="text" name="account" placeholder="e.g. WestConsin Checking" class="budget-input" style="width:200px;" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Statement File (PDF or CSV)</label>
<input type="file" name="statement" accept=".pdf,.csv" required>
</div>
<button type="submit" class="btn btn-primary" style="font-size:0.85rem;">Import</button>
</div>
</form>
</div>
</details>
{% if w.transactions %}
{% set txns_by_cat = {} %}
{% for t in w.transactions %}
{% set cat = t.category or 'Uncategorized' %}
{% if cat not in txns_by_cat %}{% set _ = txns_by_cat.update({cat: []}) %}{% endif %}
{% set _ = txns_by_cat[cat].append(t) %}
{% endfor %}
<table class="budget-table">
<thead><tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th><th></th></tr></thead>
<tbody>
{% for t in w.transactions | sort(attribute='date', reverse=True) %}
<tr>
<td class="muted" style="white-space:nowrap;font-size:0.82rem;">{{ t.date }}</td>
<td style="font-size:0.85rem;">{{ t.description }}</td>
<td>
{% if t.category %}
<span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.75rem;">{{ t.category }}{% if t.subcategory %} {{ t.subcategory }}{% endif %}</span>
{% else %}
<span class="muted" style="font-size:0.78rem;"></span>
{% endif %}
</td>
<td class="amount" style="font-size:0.85rem;">${{ t.amount | money }}</td>
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link" style="font-size:0.78rem;">Edit</a></td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">Total</td>
<td class="amount">${{ (w.transactions | map(attribute='amount') | map('float') | sum) | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.5rem 0;">No reviewed transactions for this week yet.</p>
{% endif %}
</div>
</div>
<!-- Leftover -->
<div class="leftover-bar {% if w.leftover < 0 %}leftover-negative{% endif %}" id="leftover-bar-{{ w.num }}">
<div>
<div style="font-weight:600;">Week {{ w.num }} — What's Left</div>
<div style="font-size:0.78rem;color:var(--text-light);margin-top:0.2rem;line-height:1.7;">
$<span id="lo-start-{{ w.num }}">{{ w.starting_balance | money }}</span> starting
+ $<span id="lo-income-{{ w.num }}">{{ w.income_total | money }}</span> income
&minus; $<span id="lo-spending-{{ w.num }}">{{ w.spending_total | money }}</span> spending
&minus; $<span id="lo-sinking-{{ w.num }}">{{ w.sinking_total | money }}</span> savings
&minus; $<span id="lo-debt-{{ w.num }}">{{ w.extra_debt_total | money }}</span> extra debt
</div>
</div>
<span class="leftover-amount" id="leftover-amount-{{ w.num }}"
data-spending="{{ w.spending_total }}"
data-sinking="{{ w.sinking_total }}"
data-debt="{{ w.extra_debt_total }}"
data-start="{{ w.starting_balance }}"
>${{ w.leftover | money }}</span>
</div>
</div><!-- end week-panel -->
{% endfor %}
<script>
function showWeek(num) {
document.querySelectorAll('.week-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.week-tab').forEach(t => t.classList.remove('active'));
document.getElementById('week-' + num).classList.add('active');
document.getElementById('tab-' + num).classList.add('active');
}
function saveField(year, month, week, field, key, subkey, value) {
fetch('/weekly/save', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({year, month, week, field, key, subkey, value})
}).then(() => updateLeftover(week));
}
function logIncome(year, month, week, source, value) {
fetch('/weekly/save', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({year, month, week, field: 'income', key: source, subkey: 'actual', value})
}).then(r => r.json()).then(data => {
updateLeftover(week);
// show ✓ badge
const id = 'income-actual-' + week + '-' + source.replace(/ /g,'_');
const input = document.getElementById(id);
if (input && parseFloat(value) > 0) {
let badge = input.parentElement.querySelector('.income-logged-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'income-logged-badge';
badge.textContent = '✓ logged';
input.parentElement.appendChild(badge);
}
}
});
}
function updateLeftover(week) {
// Recalculate income total from inputs
let incomeTotal = 0;
document.querySelectorAll(`#week-${week} .income-actual-input`).forEach(el => {
incomeTotal += parseFloat(el.value) || 0;
});
const totalEl = document.getElementById(`w${week}-income-actual-total`);
if (totalEl) totalEl.textContent = incomeTotal.toFixed(2);
const loIncomeEl = document.getElementById(`lo-income-${week}`);
if (loIncomeEl) loIncomeEl.textContent = incomeTotal.toFixed(2);
// Update leftover bar using all deductions from data attributes
const leftoverEl = document.getElementById(`leftover-amount-${week}`);
if (leftoverEl) {
const startBal = parseFloat(document.getElementById(`starting-balance-${week}`)?.value) || 0;
const spending = parseFloat(leftoverEl.dataset.spending) || 0;
const sinking = parseFloat(leftoverEl.dataset.sinking) || 0;
const debt = parseFloat(leftoverEl.dataset.debt) || 0;
const leftover = startBal + incomeTotal - spending - sinking - debt;
leftoverEl.textContent = '$' + leftover.toFixed(2);
const bar = document.getElementById(`leftover-bar-${week}`);
if (bar) bar.classList.toggle('leftover-negative', leftover < 0);
}
}
function logExtraDebt(debtId, value, year, month, week) {
fetch('/weekly/extra-debt', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({debt_id: debtId, amount: value, year, month, week})
});
}
function saveStartingBalance(year, month, week, value) {
fetch('/weekly/save', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({year, month, week, field: 'starting_balance', key: 'amount', subkey: 'value', value})
}).then(() => {
const loStart = document.getElementById(`lo-start-${week}`);
if (loStart) loStart.textContent = parseFloat(value || 0).toFixed(2);
updateLeftover(week);
});
}
function contributeSinking(fundName, value, year, month, week) {
fetch('/sinking/contribute-weekly', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({name: fundName, amount: value, year, month, week})
}).then(r => r.json()).then(data => {
// update the balance cell
document.querySelectorAll('.sinking-weekly-balance-' + fundName.replace(/ /g,'_'))
.forEach(el => el.textContent = '$' + (data.new_balance || 0).toFixed(2));
});
}
function toggleWeeklySection(header) {
const body = header.nextElementSibling;
const arrow = header.querySelector('.weekly-arrow');
const collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
arrow.textContent = collapsed ? '▾' : '▸';
}
// Show current week by default
showWeek({{ current_week }});
</script>
{% endblock %}

159
txns_db.py Normal file
View File

@@ -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)

18
version.py Normal file
View File

@@ -0,0 +1,18 @@
"""App version + changelog, sourced from changelog.json (single source of truth).
Cut releases with: python release.py <version> "<what changed>" ...
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"