Initial commit: moon-household-budget
This commit is contained in:
50
.claude/settings.local.json
Normal file
50
.claude/settings.local.json
Normal 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
15
.dockerignore
Normal 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
20
.gitignore
vendored
Normal 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
20
CHANGELOG.md
Normal 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
48
CLAUDE.md
Normal 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
29
Dockerfile
Normal 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"]
|
||||
87
assets.py
Normal file
87
assets.py
Normal 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
94
auth.py
Normal 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
198
auth_routes.py
Normal 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
13
backup/Dockerfile
Normal 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
44
backup/backup.sh
Normal 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
79
bills.py
Normal 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 (1–7)"
|
||||
elif day <= 14:
|
||||
return "Week 2 (8–14)"
|
||||
elif day <= 21:
|
||||
return "Week 3 (15–21)"
|
||||
else:
|
||||
return "Week 4 (22–end)"
|
||||
|
||||
|
||||
def get_week_num(day):
|
||||
if day <= 7:
|
||||
return 1
|
||||
elif day <= 14:
|
||||
return 2
|
||||
elif day <= 21:
|
||||
return 3
|
||||
else:
|
||||
return 4
|
||||
|
||||
|
||||
def bills_for_month(year, month):
|
||||
from datetime import datetime
|
||||
all_bills = load_bills()
|
||||
_, num_days = calendar.monthrange(year, month)
|
||||
result = []
|
||||
for b in all_bills:
|
||||
if not b.get("active", True):
|
||||
continue
|
||||
# Specific-date bills (BNPL payments)
|
||||
if b.get("due_date_str"):
|
||||
try:
|
||||
d = datetime.strptime(b["due_date_str"], "%Y-%m-%d").date()
|
||||
if d.year == year and d.month == month:
|
||||
result.append({**b, "due_date": d, "due_day": d.day})
|
||||
except ValueError:
|
||||
pass
|
||||
# Recurring monthly bills
|
||||
elif b.get("due_day"):
|
||||
due_day = b["due_day"]
|
||||
if 1 <= due_day <= num_days:
|
||||
result.append({**b, "due_date": date(year, month, due_day)})
|
||||
return sorted(result, key=lambda x: x.get("due_day", 0))
|
||||
|
||||
|
||||
def calendar_grid(year, month):
|
||||
_, num_days = calendar.monthrange(year, month)
|
||||
first_weekday = calendar.monthrange(year, month)[0] # 0=Mon
|
||||
# Convert to Sun-first
|
||||
first_weekday = (first_weekday + 1) % 7
|
||||
|
||||
days = []
|
||||
for _ in range(first_weekday):
|
||||
days.append(None)
|
||||
for d in range(1, num_days + 1):
|
||||
days.append(d)
|
||||
while len(days) % 7 != 0:
|
||||
days.append(None)
|
||||
|
||||
return [days[i:i+7] for i in range(0, len(days), 7)]
|
||||
37
categories.py
Normal file
37
categories.py
Normal 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
26
changelog.json
Normal 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
187
debts.py
Normal 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
33
docker-compose.yml
Normal 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
19
entrypoint.sh
Normal 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
79
income.py
Normal 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
121
mortgage.py
Normal 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
94
release.py
Normal 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
140
reminders.py
Normal 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
11
requirements.txt
Normal 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
52
settings.py
Normal 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
58
sinking.py
Normal 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: 3–6 months expenses"},
|
||||
]
|
||||
|
||||
|
||||
def load_funds():
|
||||
funds = load_json(SINKING_FILE, None)
|
||||
if funds is None:
|
||||
# First run — seed with a copy of the defaults (never hand out the
|
||||
# module-level list, which callers mutate in place).
|
||||
funds = copy.deepcopy(DEFAULT_FUNDS)
|
||||
save_funds(funds)
|
||||
return funds
|
||||
|
||||
|
||||
def save_funds(funds):
|
||||
save_json(SINKING_FILE, funds)
|
||||
|
||||
|
||||
def months_until(target_date_str):
|
||||
try:
|
||||
target = date.fromisoformat(target_date_str)
|
||||
today = date.today()
|
||||
months = (target.year - today.year) * 12 + (target.month - today.month)
|
||||
return max(months, 1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def monthly_needed(balance, goal, target_date_str):
|
||||
m = months_until(target_date_str)
|
||||
if not m:
|
||||
return None
|
||||
remaining = max(goal - balance, 0)
|
||||
return round(remaining / m, 2)
|
||||
1737
static/style.css
Normal file
1737
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
50
storage.py
Normal file
50
storage.py
Normal 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
116
suggestions/gitea.py
Normal 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}_"
|
||||
121
suggestions/routes_fastapi.py
Normal file
121
suggestions/routes_fastapi.py
Normal 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
146
suggestions/routes_flask.py
Normal 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
|
||||
87
suggestions/static/suggestions.js
Normal file
87
suggestions/static/suggestions.js
Normal 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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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`;
|
||||
$("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();
|
||||
46
suggestions/templates/suggestions.html
Normal file
46
suggestions/templates/suggestions.html
Normal 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 & Feedback</h1>
|
||||
<p class="muted">Ideas, bugs, “can it also do…?” — they go straight to Tony.</p>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="notice">Feedback isn’t 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
486
templates/assets.html
Normal 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
79
templates/base.html
Normal 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 & 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
228
templates/bills.html
Normal 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="1–31" 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
50
templates/calendar.html
Normal 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
19
templates/changelog.html
Normal 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
774
templates/debts.html
Normal 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 = "20–36% is average. Keep an eye on it." %}
|
||||
{% elif dti <= 50 %}
|
||||
{% set dti_color = "#E8956A" %}{% set dti_label = "High" %}{% set dti_tip = "36–50% is high. Prioritize paying down debt." %}
|
||||
{% else %}
|
||||
{% set dti_color = "#C57A7A" %}{% set dti_label = "Critical" %}{% set dti_tip = "Over 50% is a red flag. Focus on reducing debt ASAP." %}
|
||||
{% endif %}
|
||||
<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>
|
||||
Purchases: <strong>$${data.purchases}</strong>
|
||||
Interest: <strong>$${data.interest}</strong>
|
||||
Payment: <strong>$${data.payment}</strong>
|
||||
Minimum due: <strong>$${data.minimum}</strong>
|
||||
New balance: <strong>$${data.new_balance}</strong>
|
||||
${data.credit_limit ? ' Credit limit: <strong>$' + data.credit_limit + '</strong>' : ''}
|
||||
${data.available_credit ? ' 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
234
templates/edit.html
Normal 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
65
templates/enroll.html
Normal 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
50
templates/import.html
Normal 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
277
templates/income.html
Normal 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 ·
|
||||
${{ tax_set_aside | money }} set aside ·
|
||||
{% 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
385
templates/index.html
Normal 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 ·
|
||||
${{ tax_set_aside | money }} set aside ·
|
||||
{% 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
57
templates/login.html
Normal 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
244
templates/month.html
Normal 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
157
templates/mortgage.html
Normal 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
225
templates/net_worth.html
Normal 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
396
templates/review.html
Normal 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
83
templates/settings.html
Normal 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
175
templates/sinking.html
Normal 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
116
templates/strategy.html
Normal 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
71
templates/summary.html
Normal 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
187
templates/taxes.html
Normal 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 }} ·
|
||||
<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
168
templates/transactions.html
Normal 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
484
templates/weekly.html
Normal 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
|
||||
− $<span id="lo-spending-{{ w.num }}">{{ w.spending_total | money }}</span> spending
|
||||
− $<span id="lo-sinking-{{ w.num }}">{{ w.sinking_total | money }}</span> savings
|
||||
− $<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
159
txns_db.py
Normal 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
18
version.py
Normal 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"
|
||||
Reference in New Issue
Block a user