Envelope budget: Budget page, per-month assignments stored on a Budget tab, rollover and ready-to-assign
build-container / build (push) Successful in 50s
build-container / build (push) Successful in 50s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
837f89e563
commit
4b93d6229d
8 files changed
+235
-5
No files matched your search
+1
-1
@@ -6,7 +6,7 @@ WORKDIR /app
|
||||
COPY 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/
|
||||
|
||||
# statements, Bills_Tracker.xlsx and backups live in this volume, never in the image
|
||||
|
||||
@@ -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
|
||||
"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
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import statement_loader as L
|
||||
import budget as B
|
||||
|
||||
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)
|
||||
@@ -74,7 +75,7 @@ _DATA = {}
|
||||
def workbook_data():
|
||||
"""Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas)."""
|
||||
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
|
||||
if _DATA.get("key") == key: return _DATA["val"]
|
||||
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:
|
||||
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 ""])
|
||||
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, []
|
||||
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
|
||||
@@ -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"
|
||||
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()
|
||||
_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"]
|
||||
|
||||
def safe_name(name):
|
||||
@@ -131,6 +137,16 @@ class H(BaseHTTPRequestHandler):
|
||||
with LOCK:
|
||||
try: return self._send(200, workbook_data())
|
||||
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":
|
||||
with LOCK:
|
||||
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)
|
||||
return self._send(200, {"saved": p.name})
|
||||
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":
|
||||
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']}."})
|
||||
|
||||
+120
@@ -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=>({"&":"&","<":"<",">":">",'"':"""}[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
@@ -30,7 +30,7 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:
|
||||
<body>
|
||||
<main>
|
||||
<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="grid">
|
||||
<div class="card"><h2 id="t0">Income vs spending</h2><div id="c0"></div><div class="legend" id="l0"></div></div>
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
|
||||
</head>
|
||||
<body>
|
||||
<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 id="alerts"></div>
|
||||
|
||||
@@ -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)))
|
||||
@@ -504,6 +504,39 @@ def autocategorize(workbook=WORKBOOK):
|
||||
_save_workbook(wb, workbook)
|
||||
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):
|
||||
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}))
|
||||
|
||||
Reference in new issue
Block a user