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

+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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/ COPY template/ ./template/
# statements, Bills_Tracker.xlsx and backups live in this volume, never in the image # statements, Bills_Tracker.xlsx and backups live in this volume, never in the image
+1 -1
View File
@@ -1,6 +1,6 @@
# money # 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. Everything runs locally; statements and the workbook are **never** committed or baked into the image.
## Run on ZimaOS / CasaOS ## Run on ZimaOS / CasaOS
+20 -4
View File
@@ -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"]], 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"]], 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], 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(), 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 []) 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}) if not L.WORKBOOK.exists(): return self._send(200, {"setup": True})
return self._send(200, B.compute(workbook_data(), month)) return self._send(200, B.compute(workbook_data(), month))
except Exception as e: return self._send(500, {"error": str(e)}) 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": if u.path == "/api/budget_history":
with LOCK: with LOCK:
try: try:
@@ -197,12 +212,13 @@ class H(BaseHTTPRequestHandler):
if u.path == "/api/import": if u.path == "/api/import":
with LOCK: with LOCK:
res = L.run_preview(DATA) res = L.run_preview(DATA)
if not res["new"]: return self._send(200, {"message": "Nothing new to import."}) if not res["new"] and not res["snaps"]: return self._send(200, {"message": "Nothing new to import."})
out = L.apply(res["new"]) out = L.apply(res["new"], snaps=res["snaps"])
try: filed = L.archive_statements()["moved"] try: filed = L.archive_statements()["moved"]
except Exception: filed = 0 except Exception: filed = 0
tail = f" Filed {filed} statement(s) into Statements/year/month." if filed else "" 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"]}) "backup": Path(out["backup"]).name, "new_payees": out["new_payees"]})
if u.path == "/api/archive": if u.path == "/api/archive":
try: try:
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Savings &amp; Loans</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--ok:#1a7f4b;--okbg:#e6f4ec;--bad:#b42318;--badbg:#fdeceb;--none:#eef0f3}
@media (prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c25;--ink:#e8ebf2;--mute:#9aa3b5;--line:#2a3040;--accent:#6c8cff;--ok:#5ad08f;--okbg:#14301f;--bad:#ff8a80;--badbg:#3a1a18;--none:#222838}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}
main{max-width:1000px;margin:0 auto;padding:24px 16px 64px}
h1{font-size:22px;margin:0}h2{font-size:16px;margin:0}.mute{color:var(--mute)}.row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}.spacer{flex:1}
a{color:var(--accent);font-weight:600;text-decoration:none}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin:16px 0}
.kpi .l{font-size:12px;color:var(--mute)}.kpi .v{font-size:24px;font-weight:700;margin-top:2px}.kpi .s{font-size:12px;color:var(--mute)}
table{border-collapse:collapse;width:100%}th,td{padding:7px 8px;text-align:left;border-bottom:1px solid var(--line)}th{font-size:12px;color:var(--mute);font-weight:600}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
.big{font-size:26px;font-weight:700}
.chart{width:100%;height:120px;margin:10px 0}.chart polyline{fill:none;stroke:var(--accent);stroke-width:2}.chart circle{fill:var(--accent)}.chart text{fill:var(--mute);font-size:11px}
.facts{display:flex;gap:22px;flex-wrap:wrap;margin-top:4px}.facts div{font-size:12px;color:var(--mute)}.facts b{display:block;font-size:14px;color:var(--ink)}
.empty{text-align:center;padding:36px 16px}
.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}
@media (max-width:560px){.tabs a{padding:8px 4px;font-size:13px}}
</style>
</head>
<body>
<main>
<nav class="tabs"><a href="/">Statements</a><a href="/budget">Budget</a><a href="/dashboard">Dashboard</a><a href="/accounts" class="on" aria-current="page">Accounts</a></nav>
<h1>Savings &amp; Loans</h1>
<div class="mute" style="margin-top:2px">Balances from your statements. Kept completely separate from spending, the dashboard and the budget.</div>
<div id="out"></div>
</main>
<script>
const $=s=>document.querySelector(s);
const money=(n,d=2)=>n==null?"–":(n<0?"-":"")+"$"+Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const MON=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const dfmt=s=>{const d=new Date(s+"T12:00:00");return MON[d.getMonth()]+" "+d.getDate()+", "+d.getFullYear()};
const mshort=s=>{const d=new Date(s+"T12:00:00");return MON[d.getMonth()]+" "+String(d.getFullYear()).slice(2)};
function chart(pts){ // pts: [[date, balance]] oldest first
if(pts.length<2)return"";
const W=600,H=120,P=30,xs=pts.map((_,i)=>P+i*(W-2*P)/(pts.length-1));
const lo=Math.min(...pts.map(p=>p[1])),hi=Math.max(...pts.map(p=>p[1])),sp=(hi-lo)||1;
const ys=pts.map(p=>H-24-(p[1]-lo)/sp*(H-44));
return `<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><polyline points="${xs.map((x,i)=>x+","+ys[i]).join(" ")}"/>`+
xs.map((x,i)=>`<circle cx="${x}" cy="${ys[i]}" r="2.5"><title>${mshort(pts[i][0])}: ${money(pts[i][1])}</title></circle>`).join("")+
`<text x="${P}" y="${H-6}">${mshort(pts[0][0])}</text><text x="${W-P}" y="${H-6}" text-anchor="end">${mshort(pts[pts.length-1][0])}</text></svg>`;
}
async function load(){
const j=await (await fetch("/api/accounts")).json();
if(j.error){$("#out").innerHTML=`<div class="card" style="color:var(--bad)">${esc(j.error)}</div>`;return}
if(j.setup){$("#out").innerHTML=`<div class="card empty">Bills_Tracker.xlsx isn’t in the data folder yet. <a href="/">Set it up on the Statements tab.</a></div>`;return}
if(!j.rows.length){$("#out").innerHTML=`<div class="card empty"><b>No savings or loan balances recorded yet.</b><div class="mute" style="margin-top:6px">Add your Ally, CVFCU or mortgage statements on the <a href="/">Statements</a> tab. When it says savings / loan balances will be recorded, click <b>Add to workbook</b>.</div></div>`;return}
const by={};j.rows.forEach(r=>(by[r.account]=by[r.account]||[]).push(r));
const accts=Object.entries(by).map(([name,rs])=>{rs.sort((a,b)=>a.date<b.date?-1:1);return{name,rs,last:rs[rs.length-1],first:rs[0],group:rs[0].group}});
const sav=accts.filter(a=>a.group!=="Loan"),loan=accts.filter(a=>a.group==="Loan");
const sum=(l,f)=>l.reduce((t,a)=>t+(f(a)||0),0);
let h=`<div class="kpis">
<div class="card kpi" style="margin:0"><div class="l">Savings total</div><div class="v">${money(sum(sav,a=>a.last.balance))}</div><div class="s">${sav.length} account${sav.length===1?"":"s"}</div></div>
<div class="card kpi" style="margin:0"><div class="l">Interest earned this year</div><div class="v">${money(sum(sav,a=>a.last.ytd))}</div><div class="s">savings accounts</div></div>
<div class="card kpi" style="margin:0"><div class="l">Loan balance</div><div class="v">${money(sum(loan,a=>a.last.balance))}</div><div class="s">${loan.length?"owed":"no loans recorded"}</div></div>
<div class="card kpi" style="margin:0"><div class="l">Loan interest this year</div><div class="v">${money(sum(loan,a=>a.last.ytd))}</div><div class="s">paid so far</div></div></div>`;
for(const a of [...loan,...sav]){
const isLoan=a.group==="Loan",rs=a.rs,L=a.last,ch=L.balance-a.first.balance;
h+=`<div class="card"><div class="row"><h2>${esc(a.name)}</h2><span class="mute">${isLoan?"loan":"savings"} · as of ${dfmt(L.date)}</span><div class="spacer"></div><div class="big">${money(L.balance)}</div></div>`;
h+=`<div class="facts">`;
if(rs.length>1)h+=`<div>Since ${mshort(a.first.date)}<b style="color:${(isLoan?ch<0:ch>=0)?"var(--ok)":"var(--bad)"}">${ch>0?"+":""}${money(ch)}</b></div>`;
if(isLoan){if(L.rate!=null)h+=`<div>Interest rate<b>${L.rate}%</b></div>`;if(L.payment!=null)h+=`<div>Monthly payment<b>${money(L.payment)}</b></div>`;if(L.due)h+=`<div>Next due<b>${dfmt(L.due)}</b></div>`;
const pr=rs.reduce((t,r)=>t+(r.principal||0),0),it=rs.reduce((t,r)=>t+(r.interest||0),0);h+=`<div>Principal paid (${rs.length} mo)<b>${money(pr)}</b></div><div>Interest paid (${rs.length} mo)<b>${money(it)}</b></div>`}
else{h+=`<div>Interest this year<b>${money(L.ytd)}</b></div>`;if(L.rate)h+=`<div>APY earned<b>${L.rate}%</b></div>`}
h+=`</div>${chart(rs.map(r=>[r.date,r.balance]))}`;
h+=`<div style="overflow-x:auto"><table><tr><th>Statement</th><th class="n">Balance</th><th class="n">Interest</th>${isLoan?'<th class="n">Principal</th><th class="n">Payment</th>':'<th class="n">Deposits</th><th class="n">Withdrawals</th>'}</tr>`+
rs.slice().reverse().slice(0,12).map(r=>`<tr><td>${dfmt(r.date)}</td><td class="n">${money(r.balance)}</td><td class="n">${money(r.interest)}</td>${isLoan?`<td class="n">${money(r.principal)}</td><td class="n">${money(r.payment)}</td>`:`<td class="n">${money(r.deposits)}</td><td class="n">${money(r.withdrawals)}</td>`}</tr>`).join("")+`</table></div></div>`;
}
$("#out").innerHTML=h;
}
load();
</script>
</body>
</html>
+1 -1
View File
@@ -32,7 +32,7 @@ input.amt{width:110px;text-align:right}
</head> </head>
<body> <body>
<main> <main>
<nav class="tabs"><a href="/">Statements</a><a href="/budget" class="on" aria-current="page">Budget</a><a href="/dashboard">Dashboard</a></nav> <nav class="tabs"><a href="/">Statements</a><a href="/budget" class="on" aria-current="page">Budget</a><a href="/dashboard">Dashboard</a><a href="/accounts">Accounts</a></nav>
<div class="row"><h1>Envelope Budget</h1><div class="spacer"></div></div> <div class="row"><h1>Envelope Budget</h1><div class="spacer"></div></div>
<div class="row" style="margin:14px 0 0"> <div class="row" style="margin:14px 0 0">
<button id="prev">‹</button><b id="mlabel" style="min-width:120px;text-align:center;font-size:16px"></b><button id="next">›</button> <button id="prev">‹</button><b id="mlabel" style="min-width:120px;text-align:center;font-size:16px"></b><button id="next">›</button>
+1 -1
View File
@@ -32,7 +32,7 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:
</head> </head>
<body> <body>
<main> <main>
<nav class="tabs"><a href="/">Statements</a><a href="/budget">Budget</a><a href="/dashboard" class="on" aria-current="page">Dashboard</a></nav> <nav class="tabs"><a href="/">Statements</a><a href="/budget">Budget</a><a href="/dashboard" class="on" aria-current="page">Dashboard</a><a href="/accounts">Accounts</a></nav>
<div class="row"><h1>Bills Dashboard</h1><span class="mute" id="updated"></span><div class="spacer"></div> <div class="row"><h1>Bills Dashboard</h1><span class="mute" id="updated"></span><div class="spacer"></div>
<label class="mute">Year <select id="year"></select></label></div> <label class="mute">Year <select id="year"></select></label></div>
<div class="kpis" id="kpis"></div> <div class="kpis" id="kpis"></div>
+3 -2
View File
@@ -31,7 +31,7 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
</head> </head>
<body> <body>
<main> <main>
<nav class="tabs"><a href="/" class="on" aria-current="page">Statements</a><a href="/budget">Budget</a><a href="/dashboard">Dashboard</a></nav> <nav class="tabs"><a href="/" class="on" aria-current="page">Statements</a><a href="/budget">Budget</a><a href="/dashboard">Dashboard</a><a href="/accounts">Accounts</a></nav>
<div class="row"><h1 style="margin:0">Statement Loader</h1></div> <div class="row"><h1 style="margin:0">Statement Loader</h1></div>
<div class="sub">Runs only on this Mac. Drop in statements, check what’s covered, then add the new transactions to <b>Bills_Tracker.xlsx</b>. A backup is saved first, every time.</div> <div class="sub">Runs only on this Mac. Drop in statements, check what’s covered, then add the new transactions to <b>Bills_Tracker.xlsx</b>. A backup is saved first, every time.</div>
@@ -91,8 +91,9 @@ async function refresh(msg){
c.accounts.forEach(a=>{h+=`<tr><td><b>${esc(a.account)}</b><div class="mute" style="font-size:12px">${a.count} ${a.kind=="csv"?"download(s)":"statements"}</div></td>`+ c.accounts.forEach(a=>{h+=`<tr><td><b>${esc(a.account)}</b><div class="mute" style="font-size:12px">${a.count} ${a.kind=="csv"?"download(s)":"statements"}</div></td>`+
c.months.map(m=>{const v=a.cells[m];return `<td><span class="cell ${v}">${v=="have"?"✓":v=="missing"?"!":"·"}</span></td>`}).join("")+`<td class="mute">through ${esc(a.through)}<br>${esc(a.next)}</td></tr>`}); c.months.map(m=>{const v=a.cells[m];return `<td><span class="cell ${v}">${v=="have"?"✓":v=="missing"?"!":"·"}</span></td>`}).join("")+`<td class="mute">through ${esc(a.through)}<br>${esc(a.next)}</td></tr>`});
$("#coverage").innerHTML=h+"</table>"; $("#coverage").innerHTML=h+"</table>";
const n=s.new; $("#import").disabled=!n.length; const n=s.new; $("#import").disabled=!(n.length||s.accounts_new);
$("#summary").innerHTML=n.length?`<b>${n.length}</b> 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?` <span class="pill new">${s.summary.new_payees.length} new payee(s) to categorize</span>`:""):`Nothing new — all ${s.already} transactions in ${s.statements} files are already in the workbook.`; $("#summary").innerHTML=n.length?`<b>${n.length}</b> 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?` <span class="pill new">${s.summary.new_payees.length} new payee(s) to categorize</span>`:""):`Nothing new — all ${s.already} transactions in ${s.statements} files are already in the workbook.`;
if(s.accounts_new)$("#summary").innerHTML+=` <b>${s.accounts_new}</b> savings / loan balance(s) will be recorded on the separate Accounts tab (not in spending or the budget).`;
$("#preview").innerHTML=n.length?"<tr><th>Date</th><th>Account</th><th>Statement description</th><th>Goes to</th><th>Payee / reason</th><th class='num'>Amount</th></tr>"+n.map(r=>`<tr><td>${r.date}</td><td>${esc(r.account)}</td><td>${esc(r.desc)}</td><td><span class="pill ${r.kind=="log"?"new":"ex"}">${r.kind=="log"?"Bill Log":"Excluded"}</span></td><td>${esc(r.kind=="log"?r.payee:r.reason)}${r.new_payee?' <span class="pill new">new</span>':""}</td><td class="num">${fmt(r.amount)}</td></tr>`).join(""):""; $("#preview").innerHTML=n.length?"<tr><th>Date</th><th>Account</th><th>Statement description</th><th>Goes to</th><th>Payee / reason</th><th class='num'>Amount</th></tr>"+n.map(r=>`<tr><td>${r.date}</td><td>${esc(r.account)}</td><td>${esc(r.desc)}</td><td><span class="pill ${r.kind=="log"?"new":"ex"}">${r.kind=="log"?"Bill Log":"Excluded"}</span></td><td>${esc(r.kind=="log"?r.payee:r.reason)}${r.new_payee?' <span class="pill new">new</span>':""}</td><td class="num">${fmt(r.amount)}</td></tr>`).join(""):"";
} }
async function upload(files){ async function upload(files){
+95 -5
View File
@@ -67,6 +67,7 @@ class Statement:
txns: list = field(default_factory=list) txns: list = field(default_factory=list)
stated_out: float | None = None # total "money out" the statement itself reports (for checking) stated_out: float | None = None # total "money out" the statement itself reports (for checking)
kind: str = "pdf" kind: str = "pdf"
snaps: list = field(default_factory=list) # savings / loan balances (kept on the separate Accounts tab, never in spending)
# ---------------------------------------------------------------- PDF parsers # ---------------------------------------------------------------- PDF parsers
def _pages(path): def _pages(path):
@@ -125,6 +126,19 @@ def parse_ally(path, pages):
cur.detail = (cur.detail + " | " + line.strip()).strip(" |") cur.detail = (cur.detail + " | " + line.strip()).strip(" |")
if re.match(r"^\d{6}/\d+//", line): cur = None 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 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 return st
CV_TX = re.compile(r"^(\d\d/\d\d/\d{4}) (.+?) \$([\d,]+\.\d\d) \$([\d,]+\.\d\d)$") 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: for t in st.txns:
if "SCHEDULED TRANSFER TO LOAN" in t.desc.upper() and pr and fin: if "SCHEDULED TRANSFER TO LOAN" in t.desc.upper() and pr and fin:
t.extra = f"Principal ${pr:,.2f} / Interest ${fin:,.2f}" t.extra = f"Principal ${pr:,.2f} / Interest ${fin:,.2f}"
st.snaps = cvfcu_snapshots(full, end)
return st 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. # 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"} ACTIVITY_ACCOUNTS = {"PREFERRED CHECKING": "CVFCU Checking"}
ACT_TX = re.compile(r"^([A-Z][a-z]{2}) (\d\d) (.+?) ([+-])\$([\d,]+\.\d\d) \$(-?[\d,]+\.\d\d)$") 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 = {} _CACHE = {}
_DISK = DATA / ".statement_cache.pkl" # parsed statements survive restarts (re-reading ~30 PDFs takes several seconds) _DISK = DATA / ".statement_cache.pkl" # parsed statements survive restarts (re-reading ~30 PDFs takes several seconds)
_CACHE_VERSION = 1 _CACHE_VERSION = 2
_loaded = False _loaded = False
def _load_cache(): def _load_cache():
@@ -440,7 +484,52 @@ def _copy_style(src, dst):
from copy import copy 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 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.""" """Back up, then append new rows. Returns a summary dict."""
from openpyxl import load_workbook from openpyxl import load_workbook
BACKUPS.mkdir(exist_ok=True) 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.", "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): "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)) 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 wb.calculation.fullCalcOnLoad = True
tmp = Path(str(workbook) + ".new") tmp = Path(str(workbook) + ".new")
wb.save(tmp) wb.save(tmp)
_restore_charts(workbook, tmp) _restore_charts(workbook, tmp)
tmp.replace(workbook) 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 # ---------------------------------------------------------------- categorizing existing payees
@@ -647,7 +737,7 @@ def archived_statements():
def archive_statements(): def archive_statements():
"""Once everything read is in the workbook, move the statement files into Statements/YYYY/MM and remember what they covered.""" """Once everything read is in the workbook, move the statement files into Statements/YYYY/MM and remember what they covered."""
res = run_preview(DATA) 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 idx, batch, moved = _index(), f"{dt.datetime.now():%Y%m%d%H%M%S}", 0
for st in res["statements"]: for st in res["statements"]:
if st.file in res["bad"]: continue 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] good = [s for s in sts if s.file not in bad]
wb = load_workbook(WORKBOOK) wb = load_workbook(WORKBOOK)
new, already = plan(wb, good) 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__": if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "preview" cmd = sys.argv[1] if len(sys.argv) > 1 else "preview"