← Back to Safe to Spend Versions

Safe to Spend — Math Explorer

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).

The end-to-end model — the daily ledger

L(d) = spendableBalance + Σ inflows(≤d) − Σ outflows(≤d)projected day-by-day, today → max(month end, payday−1), user’s timezone
↓ every tab is a read of this one curve
Until 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 need
Today = dailyBudget = the sustainable RATE: min over d of L(d) / daysUntil(d)same global horizon — spending this rate never drives ANY projected day below zero
This 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 arrives
↓
Shortfalls are NEVER clampednegative floor ships as first-class shortfall fields {isShortfall, shortfallAmount, earliestShortfallDate, bindingConstraint} — Home and Coach render it identically

Outflows 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.

The components — click any card to drill in

+
Spendable Balance The ledger’s starting point — checking cash now

What it is

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.

Filters

  • account_type = 'depository' AND account_subtype = 'checking'
  • is_active = true

Load-bearing detail

Because 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.

Code reference

safe_to_spend_service.ts:computeSafeToSpend
const spendableBalance = checkingAccounts.reduce(
  (sum, a) => sum + (Number(a.availableBalance) || Number(a.currentBalance) || 0),
  0
)
−
Bills / Utils / Subs Money Out 1 — recurring outflows, conf ≥ 0.3

What it is

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.

Filters

  • effective category ∈ (bill, utility, subscription); legacy category NULL rows map via stream_type
  • is_active = true, confidence ≥ 0.3 (below → possibleBills[], NOT in the math)
  • not stale, occurrence not already paid/pending (see Ledger Mechanics)

Code reference

safe_to_spend_service.ts:computeSafeToSpend (outflow partition)
recurrence_detection_service.ts:expandOccurrences
−
Loans & Planned Savings Money Out 2+3 — obligations with their own labels

Loans (category 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 (category 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.”

Code reference

safe_to_spend_service.ts:computeSafeToSpend
('loan' / 'planned_savings' branches; totals in MoneyOutBreakdown)
−
CC Due This Cycle Money Out 4 — statement net of payments, on the due date

What it is

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.

The per-card fallback ladder (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.

Single-card payment-netting caveat

“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).

ccNextCycleWarning

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.

Code reference

safe_to_spend_service.ts:computeSafeToSpend (CC ladder loop)
safe_to_spend_service.ts:CcObligation
−
Essentials Forecast Money Out 5 — necessary daily spend, whitelist-scoped

What it is

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.

Deliberately narrower than typical-discretionary

  • Category whitelist (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.
  • Checking/debit-funded only: card-swiped groceries exit checking when the STATEMENT is paid — they’re already in the CC-due term. Counting them here too is the double-count.

Methodology (essentials_whitelist_robust_mean_dow_p90)

  • Per-weekday robust mean, p90-capped; zero-spend days included (“typical daily essentials,” not “how much when you shop”)
  • A weekday needs ≥ 4 observations or contributes $0
  • Cold start: < 14 observed days → term omitted (essentials.active = false, hero labeled “still learning”)
  • Blend phase-in: blendFactor = min(1, lookbackDays / 60) — no day-60 cliff where the hero suddenly drops by hundreds overnight

Code reference

essentials_forecast_service.ts:computeEssentialsForecast
essentials_forecast_service.ts:ESSENTIALS_PFC_PREFIXES
+
Expected Income Band-gated occurrences with floor semantics

What it is

Income stream occurrences, with the amount admitted per the app-wide confidence taxonomy (confidence_bands.ts:confidenceBand — a user declaration is ALWAYS high):

  • high (confirmed/declared or score ≥ 0.85) → full predicted amount; the declared Settings payday_amount substitutes on the confirmed stream (amountBasis: 'declared')
  • medium (0.5–0.85) → p25 of observed deposits (“estimated”) — a $700–$1,300 gig earner gets ~$800, not $0 and not the optimistic mean
  • low (< 0.5) → excluded entirely; transfer_in never counts

Floor semantics — the v5 income rule

Income 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.

Code reference

safe_to_spend_service.ts:computeSafeToSpend (income loop)
confidence_bands.ts:confidenceBand / p25
payday_service.ts:resolvePayday (declared override)
×
Ledger Mechanics Expansion · stale suppression · ±4-day dedupe

1. Occurrence expansion

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.

2. Stale suppression

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.

3. Paid/pending dedupe (±4 days)

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.

=
The Floor & The Rate deriveLedgerTabs — how the four tabs read the curve

Until Payday — the global floor (hero)

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}}.

Today — dailyBudget, the sustainable rate

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.

This Week / This Month

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.)

Shortfall — never clamped

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.

Code reference

safe_to_spend_service.ts:deriveLedgerTabs
(pure, exported, DB-free — the formula shipped with zero
arithmetic tests for two major versions; never again)
~
Spending Habits (typicalDiscretionary) − MONEY OUT 6 — subtracted since v5.4

What it is

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).

Don’t confuse it 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.”

Full math spec

→ Typical Spending Forecast spec (worked example)

Code reference

typical_discretionary_service.ts:computeTypicalDiscretionary

How the four tabs read the same curve

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

Worked example 1 — why the rate is $25/day, not $66/day

$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.

Worked example 2 — shortfall: income can’t mask an earlier dip

$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.

Open questions / known simplifications