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