Envelope budget: Budget page, per-month assignments stored on a Budget tab, rollover and ready-to-assign
build-container / build (push) Successful in 50s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 13:28:02 -04:00
1 parent 837f89e563
commit 4b93d6229d
8 files changed
+235 -5

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 app.py app_page.html app_dashboard.html ./ COPY statement_loader.py seed_rules.py budget.py app.py app_page.html app_dashboard.html app_budget.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
+6
View File
@@ -21,6 +21,12 @@ AppID=money PUID=1000 PGID=1000 TZ=America/New_York docker compose up -d # the
"Create a blank workbook" button that copies it into the data folder. Then drop statements in; new payees land on the Lists tab as "Create a blank workbook" button that copies it into the data folder. Then drop statements in; new payees land on the Lists tab as
"Other / Unsorted" for you to categorize. `loader_rules.example.json` shows the private rules file format. "Other / Unsorted" for you to categorize. `loader_rules.example.json` shows the private rules file format.
## Envelope budget
The **Budget** page (`/budget`) turns each category into an envelope. Each month, assign your income to envelopes; the goal is
"Ready to assign" = $0. Spending comes from the Bill Log, leftovers roll over, and overspending carries forward as a negative.
Tools: *Copy last month*, *Suggest from spending* (3-month average) and *Move money*. Amounts are saved to a `Budget` tab in the workbook
(one row per month and envelope), with a backup first.
## Categories ## Categories
New payees are categorized by built-in generic merchant rules (`seed_rules.py`); anything unknown lands in "Other / Unsorted". New payees are categorized by built-in generic merchant rules (`seed_rules.py`); anything unknown lands in "Other / Unsorted".
Use the **Categorize payees** card in the app (or the Lists tab) to set the rest. Your choices are saved in the workbook. Use the **Categorize payees** card in the app (or the Lists tab) to set the rest. Your choices are saved in the workbook.
+22 -2
View File
@@ -7,6 +7,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import statement_loader as L import statement_loader as L
import budget as B
PORT = int(os.environ.get("PORT", "8765")) PORT = int(os.environ.get("PORT", "8765"))
BIND = os.environ.get("BIND", "127.0.0.1") # in Docker: 0.0.0.0 (publish the port to 127.0.0.1 only) BIND = os.environ.get("BIND", "127.0.0.1") # in Docker: 0.0.0.0 (publish the port to 127.0.0.1 only)
@@ -74,7 +75,7 @@ _DATA = {}
def workbook_data(): def workbook_data():
"""Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas).""" """Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas)."""
from openpyxl import load_workbook from openpyxl import load_workbook
if not L.WORKBOOK.exists(): return dict(tx=[], income=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA)) if not L.WORKBOOK.exists(): return dict(tx=[], income=[], budget=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA))
key = L.WORKBOOK.stat().st_mtime_ns key = L.WORKBOOK.stat().st_mtime_ns
if _DATA.get("key") == key: return _DATA["val"] if _DATA.get("key") == key: return _DATA["val"]
wb = load_workbook(L.WORKBOOK, read_only=True) wb = load_workbook(L.WORKBOOK, read_only=True)
@@ -87,6 +88,11 @@ def workbook_data():
if isinstance(r[0], dt.datetime) and r[1] and r[5] is not None: if isinstance(r[0], dt.datetime) and r[1] and r[5] is not None:
c, t = pay.get(r[1], ("Other / Unsorted", "Spending")) c, t = pay.get(r[1], ("Other / Unsorted", "Spending"))
tx.append([f"{r[0]:%Y-%m-%d}", r[1], c, t, r[4] or "", round(float(r[5]), 2), r[6] or "", r[8] or ""]) tx.append([f"{r[0]:%Y-%m-%d}", r[1], c, t, r[4] or "", round(float(r[5]), 2), r[6] or "", r[8] or ""])
budget = []
if "Budget" in wb.sheetnames:
for r in wb["Budget"].iter_rows(min_row=2, max_col=3, values_only=True):
if r[0] and r[1] and isinstance(r[2], (int, float)):
budget.append([r[0] if isinstance(r[0], str) else f"{r[0]:%Y-%m}", r[1], float(r[2])])
ex, income = 0, [] ex, income = 0, []
for r in wb["Excluded Transfers"].iter_rows(min_row=5, max_col=7, values_only=True): for r in wb["Excluded Transfers"].iter_rows(min_row=5, max_col=7, values_only=True):
if not isinstance(r[0], dt.datetime): continue if not isinstance(r[0], dt.datetime): continue
@@ -96,7 +102,7 @@ def workbook_data():
src = (m.group(1).strip().title() if m else (r[2] or "Other").title()) if r[6].startswith("Income") else "Interest" src = (m.group(1).strip().title() if m else (r[2] or "Other").title()) if r[6].startswith("Income") else "Interest"
income.append([f"{r[0]:%Y-%m-%d}", src, "Interest" if r[6].startswith("Interest") else "Paycheck", round(float(r[4]), 2), r[1] or ""]) income.append([f"{r[0]:%Y-%m-%d}", src, "Interest" if r[6].startswith("Interest") else "Paycheck", round(float(r[4]), 2), r[1] or ""])
wb.close() wb.close()
_DATA["key"], _DATA["val"] = key, dict(tx=tx, income=income, categories=cats, excluded=ex, updated=f"{dt.datetime.fromtimestamp(L.WORKBOOK.stat().st_mtime):%b %-d, %Y %-I:%M %p}") _DATA["key"], _DATA["val"] = key, dict(tx=tx, income=income, budget=budget, categories=cats, excluded=ex, updated=f"{dt.datetime.fromtimestamp(L.WORKBOOK.stat().st_mtime):%b %-d, %Y %-I:%M %p}")
return _DATA["val"] return _DATA["val"]
def safe_name(name): def safe_name(name):
@@ -131,6 +137,16 @@ class H(BaseHTTPRequestHandler):
with LOCK: with LOCK:
try: return self._send(200, workbook_data()) try: return self._send(200, workbook_data())
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 == "/budget":
f = HERE / "app_budget.html"
return self._send(200, f.read_bytes().replace(b"__TOKEN__", TOKEN.encode()) if f.exists() else b"app_budget.html missing", "text/html; charset=utf-8")
if u.path == "/api/budget":
month = parse_qs(u.query).get("month", [B.this_month()])[0]
with LOCK:
try:
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 == "/api/payees": if u.path == "/api/payees":
with LOCK: with LOCK:
try: return self._send(200, L.uncategorized()) try: return self._send(200, L.uncategorized())
@@ -152,6 +168,10 @@ class H(BaseHTTPRequestHandler):
DATA.mkdir(parents=True, exist_ok=True); p.write_bytes(data) DATA.mkdir(parents=True, exist_ok=True); p.write_bytes(data)
return self._send(200, {"saved": p.name}) return self._send(200, {"saved": p.name})
body = self.rfile.read(n) body = self.rfile.read(n)
if u.path == "/api/budget":
req = json.loads(body or b"{}")
with LOCK: out = L.set_budget(req["month"], req["budgets"])
return self._send(200, {"message": f"Saved the {req['month']} budget ({out['rows']} envelopes). Backup: {out['backup']}."})
if u.path == "/api/set_categories": if u.path == "/api/set_categories":
with LOCK: out = L.set_categories(json.loads(body or b"[]")) with LOCK: out = L.set_categories(json.loads(body or b"[]"))
return self._send(200, {"message": f"Updated {out['updated']} payee(s). Backup: {out['backup']}."}) return self._send(200, {"message": f"Updated {out['updated']} payee(s). Backup: {out['backup']}."})
+120
View File
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Envelope Budget</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--ok:#1a7f4b;--okbg:#e6f4ec;--bad:#b42318;--badbg:#fdeceb;--warn:#9a6700;--warnbg:#fff4d6;--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;--warn:#f0c14b;--warnbg:#3a2f10;--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:15px;margin:0 0 10px}.mute{color:var(--mute)}.row{display:flex;gap:10px;align-items:center;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}
button,select,input{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 12px}
button{cursor:pointer}button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}button:disabled{opacity:.45;cursor:default}
.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)}
.ready.zero{background:var(--okbg)}.ready.zero .v{color:var(--ok)}.ready.neg{background:var(--badbg)}.ready.neg .v{color:var(--bad)}.ready.pos{background:var(--warnbg)}.ready.pos .v{color:var(--warn)}
table{border-collapse:collapse;width:100%}th,td{padding: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}
input.amt{width:110px;text-align:right}
.bar{height:6px;border-radius:3px;background:var(--none);margin-top:5px;overflow:hidden;min-width:120px}.bar i{display:block;height:100%;background:var(--ok)}
.bar.warn i{background:#d9a400}.bar.bad i{background:var(--bad)}
.pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:12px;font-weight:600}.pill.ok{background:var(--okbg);color:var(--ok)}.pill.bad{background:var(--badbg);color:var(--bad)}.pill.zero{background:var(--none);color:var(--mute)}
.alert{padding:10px 12px;border-radius:8px;margin:12px 0;background:var(--okbg);color:var(--ok)}.alert.bad{background:var(--badbg);color:var(--bad)}
#move{display:none;margin-top:12px}tr.empty td{color:var(--mute)}
</style>
</head>
<body>
<main>
<div class="row"><h1>Envelope Budget</h1><div class="spacer"></div><a href="/dashboard">Dashboard</a><a href="/">Statement Loader</a></div>
<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>
<select id="mpick"></select>
<div class="spacer"></div>
<button id="copy" title="Use last month's amounts">Copy last month</button>
<button id="suggest" title="Average of the last 3 months of spending">Suggest from spending</button>
<button id="movebtn">Move money</button>
<button class="primary" id="save" disabled>Save budget</button>
</div>
<div id="msg"></div>
<div class="kpis">
<div class="card kpi"><div class="l" id="il">Income this month</div><div class="v" id="inc"></div><div class="s">paychecks + interest</div></div>
<div class="card kpi"><div class="l">Budgeted this month</div><div class="v" id="bud"></div><div class="s" id="buds"></div></div>
<div class="card kpi"><div class="l">Spent this month</div><div class="v" id="spent"></div><div class="s">from the Bill Log</div></div>
<div class="card kpi ready" id="readybox"><div class="l">Ready to assign</div><div class="v" id="ready"></div><div class="s" id="readys"></div></div>
</div>
<div class="card" id="movepanel" style="display:none;margin-bottom:16px">
<h2>Move money between envelopes</h2>
<div class="row"><select id="mfrom"></select> → <select id="mto"></select><input id="mamt" class="amt" type="number" min="0" step="1" placeholder="Amount"><button id="mgo" class="primary">Move</button><button id="mcancel">Cancel</button></div>
<div class="mute" style="margin-top:6px">Changes the Budgeted amounts for this month. Remember to Save.</div>
</div>
<div class="card">
<div class="row" style="margin-bottom:6px"><h2 style="margin:0">Envelopes</h2><div class="spacer"></div><label class="mute"><input type="checkbox" id="showall"> show empty envelopes</label></div>
<table id="tbl"></table>
<p class="mute" style="margin:10px 0 0">Available = everything you’ve budgeted since the first budget month minus everything spent, so leftovers roll over and overspending carries forward. Ready to assign = income since then minus everything budgeted. Aim for $0.</p>
</div>
</main>
<script>
const TOKEN="__TOKEN__";
const $=s=>document.querySelector(s),MON=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const money=(n,d=0)=>(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 mname=m=>MON[+m.slice(5)-1]+" "+m.slice(0,4);
let S=null,rows=[],dirty=false,month=new URLSearchParams(location.search).get("month")||null;
function add(m,n){const k=+m.slice(0,4)*12+(+m.slice(5)-1)+n;return Math.floor(k/12)+"-"+String(k%12+1).padStart(2,"0")}
async function load(m){
const r=await fetch("/api/budget"+(m?"?month="+m:""));S=await r.json();
if(S.setup){document.body.innerHTML='<main><h1>Envelope Budget</h1><p>Bills_Tracker.xlsx isn’t in the data folder yet. <a href="/">Set it up on the Statement Loader page.</a></p></main>';return}
if(S.error){$("#msg").innerHTML=`<div class="alert bad">${esc(S.error)}</div>`;return}
month=S.month;rows=S.envelopes.map(e=>({...e,edit:e.budgeted}));dirty=false;
$("#mpick").innerHTML=S.months.map(x=>`<option value="${x}"${x==month?" selected":""}>${mname(x)}</option>`).join("");
$("#mlabel").textContent=mname(month);render();
}
function render(){
const sumB=rows.reduce((a,r)=>a+(+r.edit||0),0),spent=rows.reduce((a,r)=>a+r.spent,0);
const ready=S.ready+(S.budgeted_month-sumB);
$("#inc").textContent=money(S.income_month);$("#il").textContent=`Income · ${mname(month)}`;
$("#bud").textContent=money(sumB);$("#buds").textContent=S.income_month?`${Math.round(sumB/S.income_month*100)}% of this month’s income`:"";
$("#spent").textContent=money(spent);
$("#ready").textContent=money(ready);const rb=$("#readybox");const z=Math.abs(ready)<0.005;
rb.className="card kpi ready "+(z?"zero":ready<0?"neg":"pos");
$("#readys").textContent=z?"Every dollar has a job":ready<0?"Over-assigned: take money from an envelope":"Left to assign to envelopes";
$("#save").disabled=!dirty;$("#copy").disabled=!S.prev_has;
const show=$("#showall").checked;
const vis=rows.filter(r=>show||r.edit||r.spent||r.prior||r.available);
$("#tbl").innerHTML="<tr><th>Envelope</th><th class='n'>Budgeted</th><th class='n'>Spent</th><th class='n'>Available</th></tr>"+vis.map(r=>{
const i=rows.indexOf(r),avail=r.prior+(+r.edit||0)-r.spent,pot=r.prior+(+r.edit||0),pct=pot>0?Math.min(100,Math.max(0,r.spent/pot*100)):(r.spent>0?100:0);
const cls=avail<-0.005?"bad":pct>=85?"warn":"";
return `<tr><td>${esc(r.envelope)}${r.prior?`<div class="mute" style="font-size:12px">${r.prior<0?"overspent":"rolled over"} ${money(Math.abs(r.prior))} from before</div>`:""}</td>
<td class="n"><input class="amt" type="number" step="1" min="0" value="${r.edit||""}" placeholder="0" data-i="${i}"></td>
<td class="n">${money(r.spent)}</td>
<td class="n"><span class="pill ${avail<-0.005?"bad":Math.abs(avail)<0.005?"zero":"ok"}">${money(avail)}</span><div class="bar ${cls}"><i style="width:${pct}%"></i></div></td></tr>`}).join("")
+`<tr><td><b>Total</b></td><td class="n"><b>${money(sumB)}</b></td><td class="n"><b>${money(spent)}</b></td><td class="n"><b>${money(rows.reduce((a,r)=>a+r.prior+(+r.edit||0)-r.spent,0))}</b></td></tr>`;
document.querySelectorAll("input.amt").forEach(inp=>inp.oninput=()=>{rows[+inp.dataset.i].edit=inp.value===""?0:+inp.value;dirty=true;softRender(inp)});
}
function softRender(active){ // update totals without losing focus in the input
const pos=active.selectionStart,i=active.dataset.i;render();
const n=document.querySelector(`input.amt[data-i="${i}"]`);if(n){n.focus();try{n.setSelectionRange(pos,pos)}catch(e){}}
}
function go(m){if(dirty&&!confirm("Discard unsaved changes?"))return;load(m)}
$("#prev").onclick=()=>go(add(month,-1));$("#next").onclick=()=>go(add(month,1));$("#mpick").onchange=e=>go(e.target.value);
$("#showall").onchange=render;
$("#copy").onclick=async()=>{const p=await (await fetch("/api/budget?month="+add(month,-1))).json();p.envelopes.forEach(e=>{const r=rows.find(x=>x.envelope==e.envelope);if(r)r.edit=e.budgeted});dirty=true;render()};
$("#suggest").onclick=()=>{rows.forEach(r=>{if(r.suggest)r.edit=r.suggest});dirty=true;render()};
$("#movebtn").onclick=()=>{const o=rows.map(r=>`<option>${esc(r.envelope)}</option>`).join("");$("#mfrom").innerHTML=o;$("#mto").innerHTML=o;$("#movepanel").style.display="block"};
$("#mcancel").onclick=()=>$("#movepanel").style.display="none";
$("#mgo").onclick=()=>{const f=rows.find(r=>r.envelope==$("#mfrom").value),t=rows.find(r=>r.envelope==$("#mto").value),a=+$("#mamt").value;
if(!f||!t||f===t||!(a>0))return;f.edit=Math.max(0,(+f.edit||0)-a);t.edit=(+t.edit||0)+a;dirty=true;$("#movepanel").style.display="none";$("#mamt").value="";$("#showall").checked||(0);render()};
$("#save").onclick=async()=>{
const budgets={};rows.forEach(r=>{if(+r.edit)budgets[r.envelope]=+r.edit});
try{const r=await fetch("/api/budget",{method:"POST",headers:{"X-Token":TOKEN},body:JSON.stringify({month,budgets})});const j=await r.json();if(!r.ok)throw new Error(j.error);
await load(month);$("#msg").innerHTML=`<div class="alert">${esc(j.message)}</div>`}catch(e){$("#msg").innerHTML=`<div class="alert bad">${esc(e.message)}</div>`}
};
addEventListener("beforeunload",e=>{if(dirty){e.preventDefault();e.returnValue=""}});
load(month);
</script>
</body>
</html>
+1 -1
View File
@@ -30,7 +30,7 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:
<body> <body>
<main> <main>
<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><a href="/">← Statement Loader</a></div> <label class="mute">Year <select id="year"></select></label><a href="/budget">Budget</a><a href="/">← Statement Loader</a></div>
<div class="kpis" id="kpis"></div> <div class="kpis" id="kpis"></div>
<div class="grid"> <div class="grid">
<div class="card"><h2 id="t0">Income vs spending</h2><div id="c0"></div><div class="legend" id="l0"></div></div> <div class="card"><h2 id="t0">Income vs spending</h2><div id="c0"></div><div class="legend" id="l0"></div></div>
+1 -1
View File
@@ -28,7 +28,7 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
</head> </head>
<body> <body>
<main> <main>
<div class="row"><h1 style="margin:0">Statement Loader</h1><div class="spacer"></div><a href="/dashboard" style="color:var(--accent);font-weight:600;text-decoration:none">Dashboard →</a></div> <div class="row"><h1 style="margin:0">Statement Loader</h1><div class="spacer"></div><a href="/budget" style="color:var(--accent);font-weight:600;text-decoration:none;margin-right:14px">Budget</a><a href="/dashboard" style="color:var(--accent);font-weight:600;text-decoration:none">Dashboard →</a></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>
<div id="alerts"></div> <div id="alerts"></div>
+51
View File
@@ -0,0 +1,51 @@
"""Envelope budget maths. Pure functions on the dashboard data (no workbook access here).
Model: each category on the Lists tab is an envelope. Each month you assign money to envelopes ("Budgeted").
Spending comes from the Bill Log. Available = everything ever budgeted (since the first budget month) minus
everything spent in that envelope since then, so leftovers roll over and overspending carries forward as a negative.
Ready to assign = income received since the first budget month minus everything budgeted so far.
"""
import datetime as dt
from collections import defaultdict
def ym_add(ym, n):
y, m = int(ym[:4]), int(ym[5:7]); k = y * 12 + (m - 1) + n
return f"{k // 12}-{k % 12 + 1:02d}"
def this_month(): return f"{dt.date.today():%Y-%m}"
def compute(D, month):
cats = list(D.get("categories") or [])
spent = defaultdict(float) # (cat, ym)
for t in D["tx"]:
spent[(t[2], t[0][:7])] += t[5]
if t[2] not in cats: cats.append(t[2])
income = defaultdict(float)
for i in D.get("income", []): income[i[0][:7]] += i[3]
bud = defaultdict(float) # (cat, ym)
for m, env, amt in D.get("budget", []):
bud[(env, m)] += amt
if env not in cats: cats.append(env)
budget_months = sorted({m for (_, m) in bud})
start = min(budget_months[0], month) if budget_months else month
first_tx = min((t[0][:7] for t in D["tx"]), default=month)
def upto(table, c): # sum of table[(c, m)] for start <= m <= month
return sum(v for (cc, m), v in table.items() if cc == c and start <= m <= month)
rows = []
for c in cats:
b = bud.get((c, month), 0.0); s = spent.get((c, month), 0.0)
avail = upto(bud, c) - upto(spent, c)
recent = [spent.get((c, ym_add(month, -k)), 0.0) for k in (1, 2, 3) if ym_add(month, -k) >= first_tx]
suggest = round(max(0.0, sum(recent) / len(recent)) / 5) * 5 if recent else 0
rows.append(dict(envelope=c, budgeted=round(b, 2), spent=round(s, 2), prior=round(avail - b + s, 2),
available=round(avail, 2), suggest=suggest))
inc_month = income.get(month, 0.0)
inc_cum = sum(v for m, v in income.items() if start <= m <= month)
bud_cum = sum(v for (_, m), v in bud.items() if m <= month)
months, m = [], min(first_tx, start)
while m <= ym_add(this_month(), 1):
months.append(m); m = ym_add(m, 1)
return dict(month=month, start=start, envelopes=rows, income_month=round(inc_month, 2),
ready=round(inc_cum - bud_cum, 2), budgeted_month=round(sum(r["budgeted"] for r in rows), 2),
has_budget=bool(budget_months), months=months, prev_has=any(v for (c, m), v in bud.items() if m == ym_add(month, -1)))
+33
View File
@@ -504,6 +504,39 @@ def autocategorize(workbook=WORKBOOK):
_save_workbook(wb, workbook) _save_workbook(wb, workbook)
return dict(updated=n, remaining=left, backup=b.name) return dict(updated=n, remaining=left, backup=b.name)
def set_budget(month, budgets, workbook=WORKBOOK):
"""Replace the envelope amounts for one month (YYYY-MM) on the Budget tab. budgets: {envelope: amount}."""
from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
if not re.fullmatch(r"\d{4}-\d\d", month or ""): raise ValueError("bad month")
b = _backup(workbook); wb = load_workbook(workbook)
if "Budget" not in wb.sheetnames:
ws = wb.create_sheet("Budget")
ws["A1"], ws["B1"], ws["C1"] = "Month", "Envelope", "Budgeted"
for c in ("A1", "B1", "C1"):
ws[c].font = Font(name="Arial", bold=True, color="FFFFFF"); ws[c].fill = PatternFill("solid", fgColor="1F3864")
ws["E1"] = "Envelope budget amounts, one row per month and envelope. Edit them on the app's Budget page; spending comes from the Bill Log."
ws["E1"].font = Font(name="Arial", italic=True)
ws.column_dimensions["A"].width = 10; ws.column_dimensions["B"].width = 28; ws.column_dimensions["C"].width = 12
ws.freeze_panes = "A2"
ws = wb["Budget"]
keep = []
for r in ws.iter_rows(min_row=2, max_col=3, values_only=True):
if r[0] is None or r[1] is None: continue
m = r[0] if isinstance(r[0], str) else f"{r[0]:%Y-%m}"
if m != month: keep.append((m, r[1], r[2] or 0))
for r in range(2, ws.max_row + 1):
for c in (1, 2, 3): ws.cell(r, c).value = None
new = [(month, k, round(float(v), 2)) for k, v in budgets.items() if v not in (None, "") and round(float(v), 2) != 0]
for i, row in enumerate(sorted(keep + new), 2):
for c, v in enumerate(row, 1):
ws.cell(i, c).value = v
ws.cell(i, c).font = Font(name="Arial", size=10)
ws.cell(i, 3).number_format = '$#,##0.00;($#,##0.00);-'
_save_workbook(wb, workbook)
return dict(rows=len(new), backup=b.name)
def summarize(rows): def summarize(rows):
return dict(log=sum(r.kind == "log" for r in rows), excluded=sum(r.kind == "excluded" for r in rows), return dict(log=sum(r.kind == "log" for r in rows), excluded=sum(r.kind == "excluded" for r in rows),
new_payees=sorted({r.payee for r in rows if r.is_new_payee})) new_payees=sorted({r.payee for r in rows if r.is_new_payee}))