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

Envelope Budget

DashboardStatement Loader
+ +

Envelope Budget

@@ -58,6 +62,12 @@ input.amt{width:110px;text-align:right}

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.

+
diff --git a/app_dashboard.html b/app_dashboard.html index 1e9c025..c9917e0 100644 --- a/app_dashboard.html +++ b/app_dashboard.html @@ -25,12 +25,16 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height: .up{color:var(--up)}.down{color:var(--down)} #tip{position:fixed;pointer-events:none;background:var(--ink);color:var(--bg);padding:6px 9px;border-radius:6px;font-size:12px;display:none;z-index:9;max-width:260px} .bar{fill:var(--accent)}.bar:hover{opacity:.8} +.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}
+

Bills Dashboard

- Budget← Statement Loader
+

Income vs spending

diff --git a/app_page.html b/app_page.html index 6d8ab6a..55cbb49 100644 --- a/app_page.html +++ b/app_page.html @@ -24,11 +24,15 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.spacer{flex:1}.mute{color:var(--mute)}code{background:var(--none);padding:1px 5px;border-radius:4px} .legend span{margin-right:14px;font-size:12px;color:var(--mute)} #help{display:none;margin-top:10px} +.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}
-

Statement Loader

BudgetDashboard →
+ +

Statement Loader

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.
@@ -77,6 +81,8 @@ async function refresh(msg){ if(s.setup){$("#catcard").style.display="none";$("#coverage").innerHTML="";$("#preview").innerHTML="";$("#import").disabled=true; $("#summary").innerHTML="";alertBox("warn",`First-time setup: Bills_Tracker.xlsx isn’t in the data folder (${esc(s.data_dir)}). Drop your existing one into the box above, or `+(s.rules?"":" Also add loader_rules.json (see loader_rules.example.json: your name, so transfers between your own accounts aren’t counted as spending).")); const b=$("#blank");if(b)b.onclick=async()=>{try{const j=await post("/api/create_blank","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};return} + if(s.archivable>0)alertBox("good",`${s.archivable} statement file(s) are already in the workbook. `); + const fb=$("#filebtn");if(fb)fb.onclick=async()=>{try{const j=await post("/api/archive","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}}; s.bad.forEach(b=>alertBox("bad",`${esc(b.file)}: the transactions I read add up to ${fmt(b.parsed)} but the statement says ${fmt(b.stated)}. It will not be imported until that matches.`)); s.skipped.forEach(b=>alertBox("warn",`${esc(b.file)}: ${esc(b.why)}`)); s.duplicates.forEach(b=>alertBox("warn",`${esc(b.file)} is a duplicate of ${esc(b.same_as)}; counted once.`)); diff --git a/budget.py b/budget.py index 0e9fd5d..e031c4b 100644 --- a/budget.py +++ b/budget.py @@ -61,3 +61,17 @@ def compute(D, month): ready=round(inc_cum - bud_cum, 2), ready_before=round(inc_prev - bud_prev, 2), expected=exp_default, expected_saved=month in expected, 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))) + +def history(D): + """Every envelope's budgeted / spent / left-over for each month since the first budget month (left-over rolls forward).""" + first = min((m for m, env, _ in D.get("budget", [])), default=None) + if not first: return dict(months=[], envelopes=[]) + months, m = [], first + while m <= this_month(): months.append(m); m = ym_add(m, 1) + env = {} + for m in months: + for r in compute(D, m)["envelopes"]: + env.setdefault(r["envelope"], {})[m] = [r["budgeted"], r["spent"], r["available"]] + rows = [dict(envelope=e, cells=[v.get(m, [0, 0, 0]) for m in months]) for e, v in env.items() + if any(c[0] or c[1] or c[2] for c in v.values())] + return dict(months=months, envelopes=rows) diff --git a/statement_loader.py b/statement_loader.py index 970d381..a129cb6 100644 --- a/statement_loader.py +++ b/statement_loader.py @@ -217,12 +217,32 @@ def parse_capone_csv(path): return st _CACHE = {} +_DISK = DATA / ".statement_cache.pkl" # parsed statements survive restarts (re-reading ~30 PDFs takes several seconds) +_CACHE_VERSION = 1 +_loaded = False + +def _load_cache(): + global _loaded + if _loaded: return + _loaded = True + try: + import pickle + v, data = pickle.loads(_DISK.read_bytes()) + if v == _CACHE_VERSION: _CACHE.update(data) + except Exception: pass + +def _save_cache(): + try: + import pickle + tmp = _DISK.with_suffix(".tmp"); tmp.write_bytes(pickle.dumps((_CACHE_VERSION, _CACHE))); tmp.replace(_DISK) + except Exception: pass def load_statements(folder=DATA): """Parse every statement file in folder. Returns (statements, skipped, duplicates).""" sts, skipped, dups, seen = [], [], [], {} + _load_cache(); parsed_new = False for p in sorted(Path(folder).glob("*")): if p.suffix.lower() not in (".pdf", ".csv"): continue - ck = (str(p), p.stat().st_mtime_ns, p.stat().st_size) + ck = (p.name, p.stat().st_mtime_ns, p.stat().st_size) if ck in _CACHE: st, err = _CACHE[ck] if err: skipped.append((p.name, err)) @@ -244,12 +264,16 @@ def load_statements(folder=DATA): st = parse_capone_csv(p) except Exception as e: err = f"could not read: {e}" - _CACHE[ck] = (st, err) + _CACHE[ck] = (st, err); parsed_new = True if err: skipped.append((p.name, err)); continue if st is None: continue key = (st.account, st.end, st.kind) if key in seen and st.kind == "pdf": dups.append((st.file, seen[key])); continue seen[key] = st.file; sts.append(st) + if parsed_new: + live = {(p.name, p.stat().st_mtime_ns, p.stat().st_size) for p in Path(folder).glob("*")} + for k in [k for k in _CACHE if k not in live]: del _CACHE[k] # forget deleted / replaced files + _save_cache() return sts, skipped, dups # ---------------------------------------------------------------- classification @@ -603,6 +627,56 @@ 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})) +# ---------------------------------------------------------------- filing statements away +ARCHIVE = DATA / "Statements" # Statements/YYYY/MM/, by statement end date +INDEX = DATA / "statements_index.json" # what each filed statement covered, so coverage needs no re-reading + +def _index(): + try: return json.loads(INDEX.read_text()) + except Exception: return [] + +def archived_statements(): + """Light Statement objects (no transactions) for everything already filed away.""" + out = [] + for i in _index(): + try: out.append(Statement(i["file"], i["account"], dt.date.fromisoformat(i["start"]) if i.get("start") else None, + dt.date.fromisoformat(i["end"]), kind=i["kind"])) + except Exception: pass + return out + +def archive_statements(): + """Once everything read is in the workbook, move the statement files into Statements/YYYY/MM and remember what they covered.""" + res = run_preview(DATA) + if res["new"]: raise ValueError("Add the new transactions to the workbook first.") + idx, batch, moved = _index(), f"{dt.datetime.now():%Y%m%d%H%M%S}", 0 + for st in res["statements"]: + if st.file in res["bad"]: continue + src = DATA / st.file + if not src.exists(): continue + dest = ARCHIVE / f"{st.end.year}" / f"{st.end.month:02d}"; dest.mkdir(parents=True, exist_ok=True) + target, n = dest / src.name, 1 + while target.exists(): target = dest / f"{src.stem}_{n}{src.suffix}"; n += 1 + shutil.move(str(src), str(target)) + if st.kind == "pdf": idx = [i for i in idx if (i["account"], i["end"], i["kind"]) != (st.account, st.end.isoformat(), st.kind)] + idx.append(dict(file=str(target.relative_to(DATA)), account=st.account, start=st.start.isoformat() if st.start else None, + end=st.end.isoformat(), kind=st.kind, batch=batch)); moved += 1 + INDEX.write_text(json.dumps(idx, indent=1)) + if moved and res["dups"]: moved += archive_statements()["moved"] # identical copies read as duplicates until the original is filed + return dict(moved=moved) + +def unarchive_last(): + """Put the most recently filed batch of statements back in the data folder (used by Undo).""" + idx = _index() + if not idx or not any(i.get("batch") for i in idx): return 0 + last = max(i.get("batch", "") for i in idx); n = 0 + for i in [i for i in idx if i.get("batch") == last]: + src = DATA / i["file"] + if src.exists(): + target = DATA / src.name + if not target.exists(): shutil.move(str(src), str(target)); n += 1 + INDEX.write_text(json.dumps([i for i in idx if i.get("batch") != last], indent=1)) + return n + def run_preview(folder=DATA): from openpyxl import load_workbook sts, skipped, dups = load_statements(folder)