From 1f556bccfa065fa4764569bd8dc7f64f90319480 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:59:21 -0400 Subject: [PATCH] Accounts tab and page: savings and mortgage balances kept separate from spending and the budget Co-Authored-By: Claude Sonnet 5.5 --- Dockerfile | 2 +- README.md | 2 +- app.py | 24 +++++++++-- app_accounts.html | 83 ++++++++++++++++++++++++++++++++++++ app_budget.html | 2 +- app_dashboard.html | 2 +- app_page.html | 5 ++- statement_loader.py | 100 +++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 app_accounts.html diff --git a/Dockerfile b/Dockerfile index c237d5c..ad884e5 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 budget.py app.py app_page.html app_dashboard.html app_budget.html ./ +COPY statement_loader.py seed_rules.py budget.py app.py app_page.html app_dashboard.html app_budget.html app_accounts.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 94169d3..68ed2b5 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. Imported statements are filed into `Statements/year/month/` and never read again. +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. Savings and loan balances (Ally savings, CVFCU share, mortgage) go to a separate Accounts tab and page, never into spending or the budget. 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 98d5d52..752cad6 100644 --- a/app.py +++ b/app.py @@ -71,7 +71,7 @@ def state(): 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, 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"]), + already=res["already"], new=rows, accounts_new=len(res["snaps"]), archivable=(0 if (new or res["snaps"]) 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,21 @@ 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 == "/accounts": + f = HERE / "app_accounts.html" + return self._send(200, f.read_bytes().replace(b"__TOKEN__", TOKEN.encode()) if f.exists() else b"app_accounts.html missing", "text/html; charset=utf-8") + if u.path == "/api/accounts": + with LOCK: + try: + if not L.WORKBOOK.exists(): return self._send(200, {"setup": True, "rows": []}) + from openpyxl import load_workbook + wb = load_workbook(L.WORKBOOK, read_only=True, data_only=True) + try: rows = L.account_rows(wb) + finally: wb.close() + for r in rows: + for k in ("date", "due"): r[k] = r[k].isoformat() if r[k] else None + return self._send(200, {"rows": rows}) + except Exception as e: return self._send(500, {"error": str(e)}) if u.path == "/api/budget_history": with LOCK: try: @@ -197,12 +212,13 @@ class H(BaseHTTPRequestHandler): if u.path == "/api/import": with LOCK: res = L.run_preview(DATA) - if not res["new"]: return self._send(200, {"message": "Nothing new to import."}) - out = L.apply(res["new"]) + if not res["new"] and not res["snaps"]: return self._send(200, {"message": "Nothing new to import."}) + out = L.apply(res["new"], snaps=res["snaps"]) 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}", + acc = f" Recorded {out['accounts']} savings/loan balance(s) on the Accounts tab." if out["accounts"] else "" + return self._send(200, {"message": f"Added {out['log_rows']} rows to the Bill Log and {out['excluded_rows']} to Excluded Transfers.{acc}{tail}", "backup": Path(out["backup"]).name, "new_payees": out["new_payees"]}) if u.path == "/api/archive": try: diff --git a/app_accounts.html b/app_accounts.html new file mode 100644 index 0000000..43d7f7d --- /dev/null +++ b/app_accounts.html @@ -0,0 +1,83 @@ + + + + + +Savings & Loans + + + +
+ +

Savings & Loans

+
Balances from your statements. Kept completely separate from spending, the dashboard and the budget.
+
+
+ + + diff --git a/app_budget.html b/app_budget.html index 2a321da..534bd56 100644 --- a/app_budget.html +++ b/app_budget.html @@ -32,7 +32,7 @@ input.amt{width:110px;text-align:right}
- +

Envelope Budget

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

Bills Dashboard

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

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.
@@ -91,8 +91,9 @@ async function refresh(msg){ c.accounts.forEach(a=>{h+=`${esc(a.account)}
${a.count} ${a.kind=="csv"?"download(s)":"statements"}
`+ c.months.map(m=>{const v=a.cells[m];return `${v=="have"?"✓":v=="missing"?"!":"·"}`}).join("")+`through ${esc(a.through)}
${esc(a.next)}`}); $("#coverage").innerHTML=h+""; - const n=s.new; $("#import").disabled=!n.length; + const n=s.new; $("#import").disabled=!(n.length||s.accounts_new); $("#summary").innerHTML=n.length?`${n.length} new transactions found (${s.summary.log} go to the Bill Log, ${s.summary.excluded} are card payments/transfers kept off the spending totals). ${s.already} were already in the workbook.`+(s.summary.new_payees.length?` ${s.summary.new_payees.length} new payee(s) to categorize`:""):`Nothing new — all ${s.already} transactions in ${s.statements} files are already in the workbook.`; + if(s.accounts_new)$("#summary").innerHTML+=` ${s.accounts_new} savings / loan balance(s) will be recorded on the separate Accounts tab (not in spending or the budget).`; $("#preview").innerHTML=n.length?"DateAccountStatement descriptionGoes toPayee / reasonAmount"+n.map(r=>`${r.date}${esc(r.account)}${esc(r.desc)}${r.kind=="log"?"Bill Log":"Excluded"}${esc(r.kind=="log"?r.payee:r.reason)}${r.new_payee?' new':""}${fmt(r.amount)}`).join(""):""; } async function upload(files){ diff --git a/statement_loader.py b/statement_loader.py index a129cb6..2450c20 100644 --- a/statement_loader.py +++ b/statement_loader.py @@ -67,6 +67,7 @@ class Statement: txns: list = field(default_factory=list) stated_out: float | None = None # total "money out" the statement itself reports (for checking) kind: str = "pdf" + snaps: list = field(default_factory=list) # savings / loan balances (kept on the separate Accounts tab, never in spending) # ---------------------------------------------------------------- PDF parsers def _pages(path): @@ -125,6 +126,19 @@ def parse_ally(path, pages): cur.detail = (cur.detail + " | " + line.strip()).strip(" |") if re.match(r"^\d{6}/\d+//", line): cur = None st.txns = [t for t in st.txns if t.account == "Ally Checking"] # savings has no spending + heads = list(re.finditer(r"Product: (.+?) Account Ownership", full)) + for i, h in enumerate(heads): + prod = h.group(1).strip() + if prod.lower().startswith("spending"): continue + sec = full[h.end(): heads[i + 1].start() if i + 1 < len(heads) else h.end() + 3000] + g = lambda rx: (lambda m: money(m.group(1)) if m else None)(re.search(rx, sec)) + bal = g(r"Ending Balance, as of \S+ \$([\d,]+\.\d\d)") + if bal is None: continue + apy = re.search(r"Annual Percentage Yield Earned ([\d.]+)%", sec) + st.snaps.append(dict(account="Ally " + re.sub(r" Account$", "", prod), group="Savings", date=end, balance=bal, + interest=g(r"Interest Paid This Period \$([\d,]+\.\d\d)"), ytd=g(r"Interest Paid Year to Date \$([\d,]+\.\d\d)"), + deposits=g(r"Deposits and Other Credits \$([\d,]+\.\d\d)"), withdrawals=g(r"Withdrawals and Other Debits -?\$([\d,]+\.\d\d)"), + rate=float(apy.group(1)) if apy else None)) return st CV_TX = re.compile(r"^(\d\d/\d\d/\d{4}) (.+?) \$([\d,]+\.\d\d) \$([\d,]+\.\d\d)$") @@ -160,8 +174,38 @@ def parse_cvfcu(path, pages): for t in st.txns: if "SCHEDULED TRANSFER TO LOAN" in t.desc.upper() and pr and fin: t.extra = f"Principal ${pr:,.2f} / Interest ${fin:,.2f}" + st.snaps = cvfcu_snapshots(full, end) return st +def cvfcu_snapshots(full, end): + """Savings/share and loan accounts from a CVFCU statement (checking is handled as transactions).""" + bal, kind, loan = {}, {}, False + for line in full.splitlines(): + if line.startswith("Loan Account Account Number"): loan = True; continue + if line.startswith("Account Type Account Number"): loan = False; continue + m = re.match(r"^([A-Z0-9][A-Z0-9 &]+?) XXXXXX\d{4} \$(-?[\d,]+\.\d\d)$", line) + if m and "CHECKING" not in m.group(1): + bal[m.group(1)] = money(m.group(2)); kind[m.group(1)] = "Loan" if loan else "Savings" + info, blk = {n: {} for n in bal}, None + pats = dict(interest=r"Interest Paid This Period \$([\d,]+\.\d\d)", ytd=r"Interest Paid (?:Year-to-Date|YTD) \$([\d,]+\.\d\d)", + rate=r"Interest Rate ([\d.]+)%", payment=r"Payment Amount \$([\d,]+\.\d\d)", due=r"Payment Due Date (\d\d/\d\d/\d{4})", + principal=r"Principal Amount -?\$([\d,]+\.\d\d)") + for line in full.splitlines(): + h = re.match(r"^([A-Z0-9][A-Z0-9 &/\.]+?) - XXXXXX\d{4}$", line) + if h: blk = h.group(1); continue + if blk in info: + for k, rx in pats.items(): + m = re.search(rx, line) + if m and k not in info[blk]: info[blk][k] = pdate(m.group(1)) if k == "due" else float(m.group(1).replace(",", "")) + out = [] + for n, b in bal.items(): + i = info[n]; note = None + if kind[n] == "Loan" and i.get("payment") and i.get("principal") is not None: + note = f"Payment ${i['payment']:,.2f}: principal ${i['principal']:,.2f} + interest ${i.get('interest', 0):,.2f}" + out.append(dict(account="CVFCU " + n.title(), group=kind[n], date=end, balance=b, interest=i.get("interest"), ytd=i.get("ytd"), + principal=i.get("principal"), rate=i.get("rate"), payment=i.get("payment"), due=i.get("due"), note=note)) + return out + # Online "Account Details" page saved as PDF (recent activity, no statement dates). Account is named by its product line. ACTIVITY_ACCOUNTS = {"PREFERRED CHECKING": "CVFCU Checking"} ACT_TX = re.compile(r"^([A-Z][a-z]{2}) (\d\d) (.+?) ([+-])\$([\d,]+\.\d\d) \$(-?[\d,]+\.\d\d)$") @@ -218,7 +262,7 @@ def parse_capone_csv(path): _CACHE = {} _DISK = DATA / ".statement_cache.pkl" # parsed statements survive restarts (re-reading ~30 PDFs takes several seconds) -_CACHE_VERSION = 1 +_CACHE_VERSION = 2 _loaded = False def _load_cache(): @@ -440,7 +484,52 @@ def _copy_style(src, dst): from copy import copy dst.font, dst.fill, dst.border, dst.alignment, dst.number_format = copy(src.font), copy(src.fill), copy(src.border), copy(src.alignment), src.number_format -def apply(new_rows, workbook=WORKBOOK): +ACCOUNT_COLS = ["Statement date", "Account", "Type", "Balance", "Interest this period", "Interest year to date", "Deposits", "Withdrawals", + "Principal this period", "Rate %", "Payment", "Next due", "Note"] +ACCOUNT_KEYS = ["date", "account", "group", "balance", "interest", "ytd", "deposits", "withdrawals", "principal", "rate", "payment", "due", "note"] + +def account_rows(wb): + """Existing snapshots on the Accounts tab as dicts (empty if the tab doesn't exist yet).""" + if "Accounts" not in wb.sheetnames: return [] + out = [] + for r in wb["Accounts"].iter_rows(min_row=2, max_col=len(ACCOUNT_KEYS), values_only=True): + if r[0] is None or r[1] is None: continue + d = dict(zip(ACCOUNT_KEYS, r)) + for k in ("date", "due"): + if isinstance(d[k], dt.datetime): d[k] = d[k].date() + out.append(d) + return out + +def pending_snaps(wb, statements): + have = {(r["account"], r["date"]) for r in account_rows(wb)}; out = [] + for st in statements: + for sn in st.snaps: + if (sn["account"], sn["date"]) not in have: have.add((sn["account"], sn["date"])); out.append(sn) + return sorted(out, key=lambda x: (x["date"], x["account"])) + +def _write_accounts(wb, snaps): + from openpyxl.styles import Font, PatternFill + if "Accounts" not in wb.sheetnames: + ws = wb.create_sheet("Accounts") + for c, h in enumerate(ACCOUNT_COLS, 1): + cell = ws.cell(1, c, h); cell.font = Font(name="Arial", bold=True, color="FFFFFF"); cell.fill = PatternFill("solid", fgColor="1F3864") + ws["O1"] = "Savings and loan balances from statements. Kept apart from the Bill Log, spending totals and budget; see the Accounts page in the app." + ws["O1"].font = Font(name="Arial", italic=True) + for c, w in zip("ABCDEFGHIJKLM", (14, 26, 10, 14, 12, 12, 12, 12, 12, 8, 12, 12, 44)): ws.column_dimensions[c].width = w + ws.freeze_panes = "A2" + ws = wb["Accounts"] + rows = account_rows(wb) + list(snaps) + for r in range(2, ws.max_row + 1): + for c in range(1, len(ACCOUNT_KEYS) + 1): ws.cell(r, c).value = None + for i, d in enumerate(sorted(rows, key=lambda x: (x["date"], x["account"])), 2): + for c, k in enumerate(ACCOUNT_KEYS, 1): + v = d.get(k) + if isinstance(v, dt.date) and not isinstance(v, dt.datetime): v = dt.datetime.combine(v, dt.time()) + cell = ws.cell(i, c, v); cell.font = Font(name="Arial", size=10) + if k in ("date", "due"): cell.number_format = "m/d/yyyy" + elif k in ("balance", "interest", "ytd", "deposits", "withdrawals", "principal", "payment"): cell.number_format = "$#,##0.00;($#,##0.00);-" + +def apply(new_rows, workbook=WORKBOOK, snaps=None): """Back up, then append new rows. Returns a summary dict.""" from openpyxl import load_workbook BACKUPS.mkdir(exist_ok=True) @@ -509,12 +598,13 @@ def apply(new_rows, workbook=WORKBOOK): "Added to the Lists tab (category from the built-in merchant rules, or 'Other / Unsorted' when unknown) so they appear in every summary.", "Set the right Category/Type on the Lists tab (and rename the payee if you like)."), 1): rv.cell(rr, c).value = v; _copy_style(rv.cell(rr - 1, c), rv.cell(rr, c)) + if snaps: _write_accounts(wb, snaps) wb.calculation.fullCalcOnLoad = True tmp = Path(str(workbook) + ".new") wb.save(tmp) _restore_charts(workbook, tmp) tmp.replace(workbook) - return dict(backup=str(backup), log_rows=len(log_rows), excluded_rows=len(ex_rows), new_payees=added_payees) + return dict(backup=str(backup), log_rows=len(log_rows), excluded_rows=len(ex_rows), new_payees=added_payees, accounts=len(snaps or [])) # ---------------------------------------------------------------- categorizing existing payees @@ -647,7 +737,7 @@ def archived_statements(): 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.") + if res["new"] or res["snaps"]: 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 @@ -685,7 +775,7 @@ def run_preview(folder=DATA): good = [s for s in sts if s.file not in bad] wb = load_workbook(WORKBOOK) new, already = plan(wb, good) - return dict(statements=sts, skipped=skipped, dups=dups, checks=checks, bad=bad, new=new, already=already) + return dict(statements=sts, skipped=skipped, dups=dups, checks=checks, bad=bad, new=new, already=already, snaps=pending_snaps(wb, good)) if __name__ == "__main__": cmd = sys.argv[1] if len(sys.argv) > 1 else "preview"