diff --git a/Dockerfile b/Dockerfile index c6db74d..c237d5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY statement_loader.py seed_rules.py app.py app_page.html app_dashboard.html ./ +COPY statement_loader.py seed_rules.py budget.py app.py app_page.html app_dashboard.html app_budget.html ./ COPY template/ ./template/ # statements, Bills_Tracker.xlsx and backups live in this volume, never in the image diff --git a/README.md b/README.md index 77dfafd..3be0b2d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ AppID=money PUID=1000 PGID=1000 TZ=America/New_York docker compose up -d # the "Create a blank workbook" button that copies it into the data folder. Then drop statements in; new payees land on the Lists tab as "Other / Unsorted" for you to categorize. `loader_rules.example.json` shows the private rules file format. +## Envelope budget +The **Budget** page (`/budget`) turns each category into an envelope. Each month, assign your income to envelopes; the goal is +"Ready to assign" = $0. Spending comes from the Bill Log, leftovers roll over, and overspending carries forward as a negative. +Tools: *Copy last month*, *Suggest from spending* (3-month average) and *Move money*. Amounts are saved to a `Budget` tab in the workbook +(one row per month and envelope), with a backup first. + ## Categories New payees are categorized by built-in generic merchant rules (`seed_rules.py`); anything unknown lands in "Other / Unsorted". Use the **Categorize payees** card in the app (or the Lists tab) to set the rest. Your choices are saved in the workbook. diff --git a/app.py b/app.py index 1e57516..ab00382 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse, parse_qs import statement_loader as L +import budget as B PORT = int(os.environ.get("PORT", "8765")) BIND = os.environ.get("BIND", "127.0.0.1") # in Docker: 0.0.0.0 (publish the port to 127.0.0.1 only) @@ -74,7 +75,7 @@ _DATA = {} def workbook_data(): """Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas).""" from openpyxl import load_workbook - if not L.WORKBOOK.exists(): return dict(tx=[], income=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA)) + if not L.WORKBOOK.exists(): return dict(tx=[], income=[], budget=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA)) key = L.WORKBOOK.stat().st_mtime_ns if _DATA.get("key") == key: return _DATA["val"] wb = load_workbook(L.WORKBOOK, read_only=True) @@ -87,6 +88,11 @@ def workbook_data(): if isinstance(r[0], dt.datetime) and r[1] and r[5] is not None: c, t = pay.get(r[1], ("Other / Unsorted", "Spending")) tx.append([f"{r[0]:%Y-%m-%d}", r[1], c, t, r[4] or "", round(float(r[5]), 2), r[6] or "", r[8] or ""]) + budget = [] + if "Budget" in wb.sheetnames: + for r in wb["Budget"].iter_rows(min_row=2, max_col=3, values_only=True): + if r[0] and r[1] and isinstance(r[2], (int, float)): + budget.append([r[0] if isinstance(r[0], str) else f"{r[0]:%Y-%m}", r[1], float(r[2])]) ex, income = 0, [] for r in wb["Excluded Transfers"].iter_rows(min_row=5, max_col=7, values_only=True): if not isinstance(r[0], dt.datetime): continue @@ -96,7 +102,7 @@ def workbook_data(): src = (m.group(1).strip().title() if m else (r[2] or "Other").title()) if r[6].startswith("Income") else "Interest" income.append([f"{r[0]:%Y-%m-%d}", src, "Interest" if r[6].startswith("Interest") else "Paycheck", round(float(r[4]), 2), r[1] or ""]) wb.close() - _DATA["key"], _DATA["val"] = key, dict(tx=tx, income=income, categories=cats, excluded=ex, updated=f"{dt.datetime.fromtimestamp(L.WORKBOOK.stat().st_mtime):%b %-d, %Y %-I:%M %p}") + _DATA["key"], _DATA["val"] = key, dict(tx=tx, income=income, budget=budget, categories=cats, excluded=ex, updated=f"{dt.datetime.fromtimestamp(L.WORKBOOK.stat().st_mtime):%b %-d, %Y %-I:%M %p}") return _DATA["val"] def safe_name(name): @@ -131,6 +137,16 @@ class H(BaseHTTPRequestHandler): with LOCK: try: return self._send(200, workbook_data()) except Exception as e: return self._send(500, {"error": str(e)}) + if u.path == "/budget": + f = HERE / "app_budget.html" + return self._send(200, f.read_bytes().replace(b"__TOKEN__", TOKEN.encode()) if f.exists() else b"app_budget.html missing", "text/html; charset=utf-8") + if u.path == "/api/budget": + month = parse_qs(u.query).get("month", [B.this_month()])[0] + with LOCK: + try: + if not L.WORKBOOK.exists(): return self._send(200, {"setup": True}) + return self._send(200, B.compute(workbook_data(), month)) + except Exception as e: return self._send(500, {"error": str(e)}) if u.path == "/api/payees": with LOCK: try: return self._send(200, L.uncategorized()) @@ -152,6 +168,10 @@ class H(BaseHTTPRequestHandler): DATA.mkdir(parents=True, exist_ok=True); p.write_bytes(data) return self._send(200, {"saved": p.name}) body = self.rfile.read(n) + if u.path == "/api/budget": + req = json.loads(body or b"{}") + with LOCK: out = L.set_budget(req["month"], req["budgets"]) + return self._send(200, {"message": f"Saved the {req['month']} budget ({out['rows']} envelopes). Backup: {out['backup']}."}) if u.path == "/api/set_categories": with LOCK: out = L.set_categories(json.loads(body or b"[]")) return self._send(200, {"message": f"Updated {out['updated']} payee(s). Backup: {out['backup']}."}) diff --git a/app_budget.html b/app_budget.html new file mode 100644 index 0000000..e695762 --- /dev/null +++ b/app_budget.html @@ -0,0 +1,120 @@ + + + + + +Envelope Budget + + + +
+

Envelope Budget

DashboardStatement Loader
+
+ + +
+ + + + +
+
+
+
Income this month
paychecks + interest
+
Budgeted this month
+
Spent this month
from the Bill Log
+
Ready to assign
+
+ +
+

Envelopes

+
+

Available = everything you’ve budgeted since the first budget month minus everything spent, so leftovers roll over and overspending carries forward. Ready to assign = income since then minus everything budgeted. Aim for $0.

+
+
+ + + diff --git a/app_dashboard.html b/app_dashboard.html index 848e4c9..1e9c025 100644 --- a/app_dashboard.html +++ b/app_dashboard.html @@ -30,7 +30,7 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:

Bills Dashboard

- ← Statement Loader
+ Budget← Statement Loader

Income vs spending

diff --git a/app_page.html b/app_page.html index afb9cdc..c6fb3ca 100644 --- a/app_page.html +++ b/app_page.html @@ -28,7 +28,7 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
-

Statement Loader

Dashboard →
+

Statement Loader

BudgetDashboard →
Runs only on this Mac. Drop in statements, check what’s covered, then add the new transactions to Bills_Tracker.xlsx. A backup is saved first, every time.
diff --git a/budget.py b/budget.py new file mode 100644 index 0000000..61a9213 --- /dev/null +++ b/budget.py @@ -0,0 +1,51 @@ +"""Envelope budget maths. Pure functions on the dashboard data (no workbook access here). + +Model: each category on the Lists tab is an envelope. Each month you assign money to envelopes ("Budgeted"). +Spending comes from the Bill Log. Available = everything ever budgeted (since the first budget month) minus +everything spent in that envelope since then, so leftovers roll over and overspending carries forward as a negative. +Ready to assign = income received since the first budget month minus everything budgeted so far. +""" +import datetime as dt +from collections import defaultdict + +def ym_add(ym, n): + y, m = int(ym[:4]), int(ym[5:7]); k = y * 12 + (m - 1) + n + return f"{k // 12}-{k % 12 + 1:02d}" + +def this_month(): return f"{dt.date.today():%Y-%m}" + +def compute(D, month): + cats = list(D.get("categories") or []) + spent = defaultdict(float) # (cat, ym) + for t in D["tx"]: + spent[(t[2], t[0][:7])] += t[5] + if t[2] not in cats: cats.append(t[2]) + income = defaultdict(float) + for i in D.get("income", []): income[i[0][:7]] += i[3] + bud = defaultdict(float) # (cat, ym) + for m, env, amt in D.get("budget", []): + bud[(env, m)] += amt + if env not in cats: cats.append(env) + budget_months = sorted({m for (_, m) in bud}) + start = min(budget_months[0], month) if budget_months else month + first_tx = min((t[0][:7] for t in D["tx"]), default=month) + + def upto(table, c): # sum of table[(c, m)] for start <= m <= month + return sum(v for (cc, m), v in table.items() if cc == c and start <= m <= month) + rows = [] + for c in cats: + b = bud.get((c, month), 0.0); s = spent.get((c, month), 0.0) + avail = upto(bud, c) - upto(spent, c) + recent = [spent.get((c, ym_add(month, -k)), 0.0) for k in (1, 2, 3) if ym_add(month, -k) >= first_tx] + suggest = round(max(0.0, sum(recent) / len(recent)) / 5) * 5 if recent else 0 + rows.append(dict(envelope=c, budgeted=round(b, 2), spent=round(s, 2), prior=round(avail - b + s, 2), + available=round(avail, 2), suggest=suggest)) + inc_month = income.get(month, 0.0) + inc_cum = sum(v for m, v in income.items() if start <= m <= month) + bud_cum = sum(v for (_, m), v in bud.items() if m <= month) + months, m = [], min(first_tx, start) + while m <= ym_add(this_month(), 1): + months.append(m); m = ym_add(m, 1) + return dict(month=month, start=start, envelopes=rows, income_month=round(inc_month, 2), + ready=round(inc_cum - bud_cum, 2), budgeted_month=round(sum(r["budgeted"] for r in rows), 2), + has_budget=bool(budget_months), months=months, prev_has=any(v for (c, m), v in bud.items() if m == ym_add(month, -1))) diff --git a/statement_loader.py b/statement_loader.py index cc878dd..55ce06b 100644 --- a/statement_loader.py +++ b/statement_loader.py @@ -504,6 +504,39 @@ def autocategorize(workbook=WORKBOOK): _save_workbook(wb, workbook) return dict(updated=n, remaining=left, backup=b.name) + +def set_budget(month, budgets, workbook=WORKBOOK): + """Replace the envelope amounts for one month (YYYY-MM) on the Budget tab. budgets: {envelope: amount}.""" + from openpyxl import load_workbook + from openpyxl.styles import Font, PatternFill, Alignment + if not re.fullmatch(r"\d{4}-\d\d", month or ""): raise ValueError("bad month") + b = _backup(workbook); wb = load_workbook(workbook) + if "Budget" not in wb.sheetnames: + ws = wb.create_sheet("Budget") + ws["A1"], ws["B1"], ws["C1"] = "Month", "Envelope", "Budgeted" + for c in ("A1", "B1", "C1"): + ws[c].font = Font(name="Arial", bold=True, color="FFFFFF"); ws[c].fill = PatternFill("solid", fgColor="1F3864") + ws["E1"] = "Envelope budget amounts, one row per month and envelope. Edit them on the app's Budget page; spending comes from the Bill Log." + ws["E1"].font = Font(name="Arial", italic=True) + ws.column_dimensions["A"].width = 10; ws.column_dimensions["B"].width = 28; ws.column_dimensions["C"].width = 12 + ws.freeze_panes = "A2" + ws = wb["Budget"] + keep = [] + for r in ws.iter_rows(min_row=2, max_col=3, values_only=True): + if r[0] is None or r[1] is None: continue + m = r[0] if isinstance(r[0], str) else f"{r[0]:%Y-%m}" + if m != month: keep.append((m, r[1], r[2] or 0)) + for r in range(2, ws.max_row + 1): + for c in (1, 2, 3): ws.cell(r, c).value = None + new = [(month, k, round(float(v), 2)) for k, v in budgets.items() if v not in (None, "") and round(float(v), 2) != 0] + for i, row in enumerate(sorted(keep + new), 2): + for c, v in enumerate(row, 1): + ws.cell(i, c).value = v + ws.cell(i, c).font = Font(name="Arial", size=10) + ws.cell(i, 3).number_format = '$#,##0.00;($#,##0.00);-' + _save_workbook(wb, workbook) + return dict(rows=len(new), backup=b.name) + def summarize(rows): return dict(log=sum(r.kind == "log" for r in rows), excluded=sum(r.kind == "excluded" for r in rows), new_payees=sorted({r.payee for r in rows if r.is_new_payee}))