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
+1
-1
@@ -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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 & 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 & 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=>({"&":"&","<":"<",">":">",'"':"""}[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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user