Accounts tab and page: savings and mortgage balances kept separate from spending and the budget
build-container / build (push) Successful in 44s
build-container / build (push) Successful in 44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
20317b14c9
commit
1f556bccfa
8 files changed
+205
-15
No files matched your search
+95
-5
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user