Markdown companion: docs/SAFE_TO_SPEND_MATH.md
v5.4 (2026-06-12, global floor + habits in Money Out): one primitive — a projected daily cash-flow ledger — drives every number on the card, and one invariant bounds every tab: no window ever promises money a longer window proves you need. Click into any component to see exactly how it’s calculated, what window it covers, and how each tab reads the same curve.
How this page is structured:
Engine: safe_to_spend_service.ts:computeSafeToSpend · pure tab math: safe_to_spend_service.ts:deriveLedgerTabs (exported, DB-free, unit-tested).
L(d) = spendableBalance + Σ inflows(≤d) − Σ outflows(≤d)projected day-by-day, today → max(month end, payday−1), user’s timezoneUntil Payday = the GLOBAL FLOOR: min L(d), d ∈ [today, max(payday−1, month end)]v5.1 — the most you can spend right now and still cover every projected obligation; the hero number. The one invariant: no window ever promises money a longer window proves you needToday = dailyBudget = the sustainable RATE: min over d of L(d) / daysUntil(d)same global horizon — spending this rate never drives ANY projected day below zeroThis Week = min(rate × daysInWindow, global floor)allocation view, capped — a shorter tab can never exceed what the full curve proves spendable (v5.0's week capped only pre-payday; a post-payday bill made This Week > This Month — the cross-horizon seam, dead in v5.1)This Month = same allocation when month-end ≤ payday−1; the global floor itself when the month crosses paydaypaychecks land on their dates — income is never flat-added before it arrivesShortfalls are NEVER clampednegative floor ships as first-class shortfall fields {isShortfall, shortfallAmount, earliestShortfallDate, bindingConstraint} — Home and Coach render it identicallyOutflows are a MECE partition (bills/utils/subs · loans · planned savings · CC due-this-cycle · essentials · habits — v5.4); inflows are band-gated income occurrences. Everything recurring is occurrence-expanded: a 3-paycheck month counts 3 paychecks; a weekly bill counts every week. v5.4: the habits forecast (moneyOut.habits) is SUBTRACTED — the hero is what’s free even if your habits repeat; the old context-only projection line is retired.
Sum of your active checking accounts only, using availableBalance when Plaid provides it (nets pending debits automatically) and falling back to currentBalance. This seeds the entire curve. Savings, money-market, HSA, and CD balances stay excluded and surface as setAsideAccounts[] — money you’ve moved to savings shouldn’t feel spendable.
account_type = 'depository' AND account_subtype = 'checking'is_active = trueBecause availableBalance already nets pending debits, the ±4-day paid/pending dedupe (see Ledger Mechanics) must drop any projected occurrence matched to a pending transaction — otherwise the same bill hits the curve twice.
safe_to_spend_service.ts:computeSafeToSpend const spendableBalance = checkingAccounts.reduce( (sum, a) => sum + (Number(a.availableBalance) || Number(a.currentBalance) || 0), 0 )
Occurrence-expanded outflows for streams with effective category bill, utility, or subscription and confidence_score ≥ 0.3 (BILLS_MIN_CONFIDENCE). Each occurrence lands on its predicted date in the ledger; each window’s moneyOut.billsUtilsSubs sums what falls inside it.
bill, utility, subscription); legacy category NULL rows map via stream_typeis_active = true, confidence ≥ 0.3 (below → possibleBills[], NOT in the math)safe_to_spend_service.ts:computeSafeToSpend (outflow partition) recurrence_detection_service.ts:expandOccurrences
loan)Loan payment streams — car payment, student-loan autopay. Included regardless of confidence score (a detected loan payment is a real obligation); still subject to stale suppression and dedupe. Loan account balances stay out of the math entirely (loansExcluded[] — your mortgage principal isn’t due this month).
planned_savings)Recurring checking→savings transfers. They reduce Safe to Spend — the money does leave checking — but carry their own moneyOut.plannedSavings label, never lumped into “bills.” “You can’t spend this because you’re SAVING it” is a different story from “Comcast takes it.”
safe_to_spend_service.ts:computeSafeToSpend
('loan' / 'planned_savings' branches; totals in MoneyOutBreakdown)
Per card, the amount actually due this cycle, landing on the card’s due date in the ledger — never the total revolving balance (v4’s “acknowledged semantic weirdness,” now fixed). creditCardDebt (total owed) is still returned but is display-only; it is never subtracted anywhere in v5.
ccObligations[].basis)statement — max(0, last_statement_balance − payments since issue) on next_payment_due_date. Due date passed + overdue → due NOW (lands today); passed + not overdue → cycle settled → $0.stream_estimate — no statement data: a detected checking→card autopay stream’s predictedNextAmount on its date, labeled “estimated.”minimum — no stream either: minimum_payment_amount on the due date.unknown — none of the above with a balance > 0 → $0 enters the math and ccDueUnknown = true so the UI says “we can’t see what’s due” instead of silently pretending.“Payments since issue” comes from checking debits matching LOAN_PAYMENTS_CREDIT_CARD% — which carry no per-card mapping. Netting therefore only applies with exactly ONE card; multi-card users get the raw statement per card (conservative).
Per card, nextCycleAccruing = max(0, totalBalance − statement); the sum ships as ccNextCycleWarning {amount, monthLabel, escalate} — “$X building toward your next statement,” escalating to a Home banner when it exceeds 50% of the expected paycheck (CC_WARNING_PAYCHECK_RATIO). Server-computed so the Coach narrates the identical rule.
safe_to_spend_service.ts:computeSafeToSpend (CC ladder loop) safe_to_spend_service.ts:CcObligation
Projected daily NECESSARY non-recurring spend, spread per-weekday across every ledger day — the term that stops the hero from saying “spend $200” when $90 of it is groceries you will definitely buy.
ESSENTIALS_PFC_PREFIXES, v1): groceries, gas, public transit, pharmacy, primary care, childcare. The non-essential remainder is its own subtracted term since v5.4 (Spending habits, below) — the two stay separately labeled and learnable.essentials.active = false, hero labeled “still learning”)blendFactor = min(1, lookbackDays / 60) — no day-60 cliff where the hero suddenly drops by hundreds overnightessentials_forecast_service.ts:computeEssentialsForecast essentials_forecast_service.ts:ESSENTIALS_PFC_PREFIXES
Income stream occurrences, with the amount admitted per the app-wide confidence taxonomy (confidence_bands.ts:confidenceBand — a user declaration is ALWAYS high):
payday_amount substitutes on the confirmed stream (amountBasis: 'declared')transfer_in never countsIncome is never flat-added to a window. Each paycheck occurrence lands on its date, lifting L(d) only for days at/after arrival — a paycheck 5 days out cannot rescue rent due in 2 days (see the shortfall example). Occurrence expansion applies: a 3-paycheck month counts all 3.
safe_to_spend_service.ts:computeSafeToSpend (income loop) confidence_bands.ts:confidenceBand / p25 payday_service.ts:resolvePayday (declared override)
Every included stream is expanded over [today, range end] via recurrence_detection_service.ts:expandOccurrences (period/anchor-day aware). v4 counted only the single predictedNextDate; v5 counts every occurrence in the range — weekly bills count every week.
A stream whose predictedNextDate is > 0.75× its detected period overdue with no matching transaction is treated as likely-cancelled: demoted to possibleBills[], out of the math (isStreamStale). Applies to all outflow categories, including loans and planned savings.
A projected occurrence is DROPPED when a member transaction (posted OR pending) exists within DEDUPE_TOLERANCE_DAYS = 4 days of it — the balance already reflects it (availableBalance nets pending debits). Applies to outflows AND income.
min L(d), d ∈ [today, max(payday−1, month end)] (v5.1). The display window still ends payday−1 — the awaited paycheck is never inside its own runway — but the CAP looks through the full displayed horizon, so a post-payday bill that no trusted income covers caps the hero too. When the paycheck IS confidently detected it lifts the post-payday curve and the global floor collapses to the old pre-payday floor. The response names the binding dip: floor {date, value, crossesPayday, bindingBill {label, amount, date}}.
x = min over d of L(d)/daysUntil(d). Spending x/day never drives ANY projected day below zero (global horizon, v5.1). Deliberately NOT floor ÷ daysUntilPayday and NOT floor-day balance ÷ days-to-floor-day — see the worked example for why $25/day is right where $66/day digs −$300.
Week: min(x × daysInWindow, global floor) — a shorter tab can never exceed what the full curve proves spendable. Month: same allocation when month-end ≤ payday−1; when the month crosses payday, the global floor itself — paychecks land on their dates. (v5.0 capped the week at the pre-payday floor only; one uncovered post-payday bill produced This Week > This Month in the field on 2026-06-12 — the v5.1 global floor is the fix.)
Negative floor → EVERY window collapses to the floor, Today included (a negative rate × more days would read “deeper in the hole the longer you wait”, and a raw per-day rate next to a deeper weekly floor reads as a smaller hole on a shorter tab — v5.1) and the response carries shortfall {isShortfall, shortfallAmount, earliestShortfallDate, bindingConstraint {date, label}}. Home renders it; the Coach quotes the identical projection. v4 clamped negatives to $0 in the Coach; v5 never clamps anywhere.
safe_to_spend_service.ts:deriveLedgerTabs (pure, exported, DB-free — the formula shipped with zero arithmetic tests for two major versions; never again)
Projected daily non-recurring, NON-ESSENTIAL discretionary spend (dining, shopping, entertainment) — robust per-weekday mean, p90-capped, zero-spend days included, up to a year of lookback; <60d history → term omitted. Spread across every ledger day like essentials; rendered as the “Spending habits” Money Out row tagged “estimated.” v5.4 scope: checking/debit-funded only, like essentials (the ccDue ladder carries card swipes; set-aside depository subtypes never draw on the runway) and the sample excludes ESSENTIALS_PFC_PREFIXES (no double-count with essentials).
Essentials = the necessities whitelist (can’t-skip categories). Spending habits = everything non-recurring OUTSIDE that whitelist. Both are subtracted since v5.4, and they are MECE by construction (the habits sample excludes the essentials prefixes). The v4.x “circularity” rationale for keeping habits out was re-decided 2026-06-12: the hero now answers “what’s truly free even if your habits repeat.”
→ Typical Spending Forecast spec (worked example)
typical_discretionary_service.ts:computeTypicalDiscretionary
One ledger, four reads. The curve L(d) is tab-invariant; what changes is the window end and whether the tab is a floor, a rate, or a capped allocation.
| Aspect | Today | This Week | This Month | Until Payday |
|---|---|---|---|---|
| Ledger curve L(d) | Same one curve — every tab is a read of it | |||
| Window end | today | end of ISO week (user tz) | end of calendar month | payday−1 (paycheck never inside its own runway) |
| Tab amount | rate x = min(L(d)/daysUntil(d)) |
min(x × days, global floor) |
allocation, or the global floor when crossing payday | global floor min L(d) through max(payday−1, month end) |
| moneyOut breakdown | events ≤ today | events ≤ week end | events ≤ month end | events ≤ payday−1 |
| Essentials term | today’s weekday value | Σ daily values in window | Σ in window | Σ in window |
| In shortfall | floor (v5.1 — was the raw negative rate) | floor | floor | floor (= shortfallAmount) |
| Spending habits (moneyOut.habits) | sum of habitsByDay inside each window — SUBTRACTED like essentials (v5.4); term omitted <60d history | |||
$1,000 in checking. $800 rent due day 3. Payday day 9 → pre-payday window is days 1–8. No other events.
Day d: 1 2 3 4 5 6 7 8 | 9 (payday) Event: — — −800 — — — — — | +paycheck L(d): 1000 1000 200 200 200 200 200 200 | L(d)/d: 1000 500 66.67 50 40 33.33 28.57 25.00 | Until Payday (floor) = min L(d) = $200 Today (rate) = min L(d)/d = 200/8 = $25.00/day This Week (ends day 7) = min(25 × 7, 200) = $175 This Month (ends day 8) = min(25 × 8, 200) = $200
Why NOT $66/day: $66.67 is 200/3 — it merely clears the rent-day dip. Keep spending $66.67/day through day 8 and you’ve spent ~$533 against $200 of post-rent headroom: 1000 − 800 − 533 ≈ −$333 by payday. The min runs over every projected day, so the binding ratio is 200/8 and $25/day keeps every day ≥ $0.
If the month crossed payday (month-end day 20, $3,000 paycheck day 9), the global floor runs over days 1–20 — the paycheck lifts L(d) only from day 9 onward, so the floor stays $200 and every tab is unchanged. Income is never flat-added into earlier days. Now remove the paycheck from the projection (income the engine can't trust is excluded) and add a $250 bill on day 12: L(12) = 200 − 250 = −$50, so the global floor is −$50 and EVERY tab reports the shortfall. v5.0 would have said “$200 safe until payday” while the month tab alone knew about day 12 — that cross-horizon seam (This Week > This Month, observed in the field 2026-06-12) is exactly what the v5.1 global floor kills.
$500 in checking. $1,000 rent due day 2. $2,000 paycheck lands day 5 (payday) → pre-payday window is days 1–4.
Day d: 1 2 3 4 | 5 (payday) Event: — −1000 — — | +2000 L(d): 500 −500 −500 −500 | +1500 L(d)/d: 500 −250 −166.67 −125 | Until Payday (floor) = −$500 ← NOT clamped Today = floor (every window collapses in shortfall) = −$500 This Week / Month = floor = −$500 shortfall = { isShortfall: true, shortfallAmount: −500, earliestShortfallDate: day 2, bindingConstraint: { date: day 2, label: "rent" } }
The $2,000 paycheck makes the post-payday curve healthy (+$1,500) but cannot mask the day-2 dip — floor semantics means income only helps days at/after it arrives. v4 flat-added window income and the Coach clamped negatives to $0; v5 tells the truth: you are $500 short, starting day 2, because of rent.
LOAN_PAYMENTS_CREDIT_CARD%) carry no per-card mapping, so statement netting only applies to single-card users; multi-card users get the conservative raw statement per card. Needs per-card payment attribution.user_confirmed=true bypasses the occurrence-count threshold.ESSENTIALS_PFC_PREFIXES covers groceries, fuel/transit, pharmacy, primary care, childcare. Extend deliberately — in the spec and the code together.setAsideAccounts[] (“Set aside in savings”). Encourages users to keep savings savings.loan); loan balances surface as loansExcluded[] only.