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.
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+=`
`});
$("#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?"
`).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"