diff --git a/README.md b/README.md
index 5fae278..94169d3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# money
-Local tool that loads bank/card statements (Chase Visa, Ally, CVFCU PDFs; CVFCU "Account Details" page saved as PDF for recent activity; Capital One CSV) into `Bills_Tracker.xlsx` and shows a web dashboard.
+Local tool that loads bank/card statements (Chase Visa, Ally, CVFCU PDFs; CVFCU "Account Details" page saved as PDF for recent activity; Capital One CSV) into `Bills_Tracker.xlsx` and shows a web dashboard. Imported statements are filed into `Statements/year/month/` and never read again.
Everything runs locally; statements and the workbook are **never** committed or baked into the image.
## Run on ZimaOS / CasaOS
diff --git a/app.py b/app.py
index 78b74ac..98d5d52 100644
--- a/app.py
+++ b/app.py
@@ -68,10 +68,10 @@ def state():
rows = [dict(date=f"{r.txn.date:%Y-%m-%d}", account=r.txn.account, desc=r.txn.desc[:60], amount=r.txn.amount, kind=r.kind,
payee=r.payee, what=r.what, reason=r.reason, new_payee=r.is_new_payee) for r in new]
sm = L.summarize(new)
- return dict(coverage=coverage(res["statements"]), skipped=[dict(file=f, why=w) for f, w in res["skipped"]],
+ return dict(coverage=coverage(res["statements"] + L.archived_statements()), skipped=[dict(file=f, why=w) for f, w in res["skipped"]],
duplicates=[dict(file=a, same_as=b) for a, b in res["dups"]],
bad=[dict(file=f, parsed=p, stated=s) for f, p, s, ok in res["checks"] if not ok],
- already=res["already"], new=rows, summary=sm, statements=len(res["statements"]),
+ already=res["already"], new=rows, archivable=(0 if new else len([x for x in res["statements"] if x.file not in res["bad"]])), summary=sm, statements=len(res["statements"]),
excel_open=(DATA / "~$Bills_Tracker.xlsx").exists() or (DATA / ".~lock.Bills_Tracker.xlsx#").exists(),
backups=sorted(p.name for p in L.BACKUPS.glob("*.xlsx"))[-5:] if L.BACKUPS.exists() else [])
@@ -151,6 +151,12 @@ class H(BaseHTTPRequestHandler):
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/budget_history":
+ with LOCK:
+ try:
+ if not L.WORKBOOK.exists(): return self._send(200, dict(months=[], envelopes=[]))
+ return self._send(200, B.history(workbook_data()))
+ 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())
@@ -193,15 +199,24 @@ class H(BaseHTTPRequestHandler):
res = L.run_preview(DATA)
if not res["new"]: return self._send(200, {"message": "Nothing new to import."})
out = L.apply(res["new"])
- return self._send(200, {"message": f"Added {out['log_rows']} rows to the Bill Log and {out['excluded_rows']} to Excluded Transfers.",
+ try: filed = L.archive_statements()["moved"]
+ except Exception: filed = 0
+ tail = f" Filed {filed} statement(s) into Statements/year/month." if filed else ""
+ return self._send(200, {"message": f"Added {out['log_rows']} rows to the Bill Log and {out['excluded_rows']} to Excluded Transfers.{tail}",
"backup": Path(out["backup"]).name, "new_payees": out["new_payees"]})
+ if u.path == "/api/archive":
+ try:
+ with LOCK: out = L.archive_statements()
+ except ValueError as e: return self._send(400, {"error": str(e)})
+ return self._send(200, {"message": f"Filed {out['moved']} statement(s) into Statements/year/month. They won't be read again."})
if u.path == "/api/undo":
with LOCK:
b = sorted(L.BACKUPS.glob("*.xlsx")) if L.BACKUPS.exists() else []
if not b: return self._send(200, {"message": "No backups to restore."})
keep = L.BACKUPS / f"before_undo_{dt.datetime.now():%Y%m%d_%H%M%S}.xlsx"
shutil.copy2(L.WORKBOOK, keep); shutil.copy2(b[-1], L.WORKBOOK)
- return self._send(200, {"message": f"Restored the workbook from {b[-1].name}."})
+ back = L.unarchive_last()
+ return self._send(200, {"message": f"Restored the workbook from {b[-1].name}." + (f" Moved {back} statement(s) back to the data folder." if back else "")})
if u.path == "/api/create_blank":
tpl = HERE / "template" / "Bills_Tracker.template.xlsx"
with LOCK:
diff --git a/app_budget.html b/app_budget.html
index 1d13f88..2a321da 100644
--- a/app_budget.html
+++ b/app_budget.html
@@ -25,11 +25,15 @@ input.amt{width:110px;text-align:right}
.pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:12px;font-weight:600}.pill.ok{background:var(--okbg);color:var(--ok)}.pill.bad{background:var(--badbg);color:var(--bad)}.pill.zero{background:var(--none);color:var(--mute)}
.alert{padding:10px 12px;border-radius:8px;margin:12px 0;background:var(--okbg);color:var(--ok)}.alert.bad{background:var(--badbg);color:var(--bad)}
#move{display:none;margin-top:12px}tr.empty td{color:var(--mute)}
+.tabs{position:sticky;top:0;z-index:5;display:flex;gap:4px;margin:0 0 16px;padding:10px 0;background:var(--bg)}
+.tabs a{flex:1;max-width:170px;text-align:center;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--mute);font-weight:600;font-size:14px;text-decoration:none}
+.tabs a:hover{color:var(--ink)}.tabs a.on{background:var(--accent);border-color:var(--accent);color:#fff}
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. Income paid in pieces (like the 15th and the last day)? Type the month’s full total in “Expecting”, and it counts as soon as you budget it. Aim for $0.
+
+
History by month
+
+
+
Left over is what the envelope held at the end of that month: everything budgeted so far minus everything spent. It is worked out from the Budget tab and Bill Log, so it updates if you edit a past month.