Accounts tab and page: savings and mortgage balances kept separate from spending and the budget
build-container / build (push) Successful in 44s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 15:59:21 -04:00
1 parent 20317b14c9
commit 1f556bccfa
8 files changed
+205 -15

No files matched your search

+95 -5
View File
@@ -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"