"""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}" EXPECTED = "Expected income" # stored on the Budget tab like an envelope row, but it is income you plan on, not an envelope 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) expected = {} # ym -> income you expect this month (paychecks still to come) for m, env, amt in D.get("budget", []): if env == EXPECTED: expected[m] = amt; continue bud[(env, m)] += amt if env not in cats: cats.append(env) budget_months = sorted({m for (_, m) in bud} | set(expected)) 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) # income that counts for a month = whichever is bigger: what actually arrived, or what you said to expect counted = lambda m: max(income.get(m, 0.0), expected.get(m, 0.0)) prev = ym_add(month, -1) inc_prev = sum(counted(m) for m in {*income, *expected} if start <= m <= prev) bud_prev = sum(v for (_, m), v in bud.items() if m <= prev) prev_income = round(income.get(prev, 0.0), -1) # a month from today on starts out expecting last month's income, until you set a figure exp_default = expected.get(month, prev_income if month >= this_month() else 0.0) inc_cum = inc_prev + counted(month); bud_cum = bud_prev + 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), ready_before=round(inc_prev - bud_prev, 2), expected=exp_default, expected_saved=month in expected, 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))) def history(D): """Every envelope's budgeted / spent / left-over for each month since the first budget month (left-over rolls forward).""" first = min((m for m, env, _ in D.get("budget", [])), default=None) if not first: return dict(months=[], envelopes=[]) months, m = [], first while m <= this_month(): months.append(m); m = ym_add(m, 1) env = {} for m in months: for r in compute(D, m)["envelopes"]: env.setdefault(r["envelope"], {})[m] = [r["budgeted"], r["spent"], r["available"]] rows = [dict(envelope=e, cells=[v.get(m, [0, 0, 0]) for m in months]) for e, v in env.items() if any(c[0] or c[1] or c[2] for c in v.values())] return dict(months=months, envelopes=rows)