Markdown companion: docs/FEATURE_VERSIONS_SAFE_TO_SPEND.md
Twenty-one documented iterations from a single hero number to a behaviorally-tuned runway with savings deliberately set aside, drill-downs on every aggregate, and per-tab projections. Each version shipped on real user data, each change was earned.
→ Safe to Spend math explorer — click-to-expand component breakdown of the whole formula → Typical Spending Forecast spec — day-by-day worked example
Three big shifts across five weeks of iteration:
safe_to_spend_service.ts (v3.1) → DB as single source of truth (v4.4)dropShadowIncome() — once paycheck slices are confirmed on an account, an unconfirmed income stream on the same account within 5% of a confirmed slice is the same deposit and is dropped, in the ledger and in the Coach’s payday tool.[today, max(payday−1, month end)] and on payday the runway was “just today”. On 30 Sep 2026 (payday and month end) the pace became “cash + today’s paycheck ÷ 1 day”, and This Week multiplied it by 5 days the engine never looked at — a weekly figure larger than all cash plus all income.globalIdx = max(paydayIdx, monthEndIdx, weekEndIdx).runwayPayday(); new runwayPayday field). Home: “payday today 🎉 · safe until next payday (Thu Oct 15)”.x = min L(d)/(d+1) already clears every dip. This Month legitimately exceeds cash when it crosses a paycheck; the floor survives as the dip footnote and shortfall amount.moneyOut.habits, spread per-weekday across every ledger day exactly like essentials. New “Spending habits” SubRowCard tagged “estimated”; the projection line + methodology sheet are retired — the hero already IS the habits-repeat number.account_type = 'depository'.ESSENTIALS_PFC_PREFIXES (NULL categories kept — real purchases), making essentials + habits MECE by construction. Methodology: habits_excl_essentials_robust_mean_dow_p90.typicalDiscretionary = null, row hidden, “doesn’t include your spending habits yet” footnote; the still-learning footnote now covers whichever estimated terms are inactive). Coach tool description + system prompt flipped; projection_accuracy eval ground truths flipped both directions.floor.values in three rewritten eval mocks; doc drift across six spec surfaces. Locked conventions (SAFE_TO_SPEND_MATH.md): the day-60 cliff and the full-day-today charge.Timeline gap: v5.3 (2026-06-12, PRs #207–#211) was the paycheck/detection hardening wave — multi-account direct-deposit splits, semimonthly two-anchor expansion, confirmed-stream ownership contract, 30-day sync lookback, poison-row hardening. Detailed entry pending.
partitionStreams() in safe_to_spend_service.ts (DB-free unit-testable, the deriveLedgerTabs precedent): outflow, income, and cc_payment streams enter the ledger ONLY when sourced from a depository account, or legacy rows with no account linkage — dropping an unlinked paycheck would be a worse trust failure than the bugs this fixes.excludedNonDepository and deliberately do NOT feed possibleBills — a card-billed subscription must never resurface as a checking “possible bill.”Timeline gap: v5.0–v5.1 (2026-06-10 → 06-12, PRs #188–#193) were the daily cash-flow ledger rework — MECE Money Out, the Plaid Liabilities CC due ladder, payday/income v2, the global-floor invariant, and the ledger UI. Full current-state spec: the math explorer / SAFE_TO_SPEND_MATH.md (includes the v4.9 → v5 deltas table). Detailed timeline entries for v5.0/v5.1 are pending.
account_type = 'depository' to account_type = 'depository' AND account_subtype = 'checking'. Savings, money-market, HSA, and CD balances no longer count toward Safe to Spend.setAsideAccounts[] field on the API response — savings/HSA/MM/CD accounts surfaced separately for transparency (mirror of the existing loansExcluded[] pattern).Vik: "Yes, I think we need to help people save more. I would like the safe to spend component to reflect that in the math and UX. Yes, it's a deliberate choice, not a hacky label choice."
Including savings in “safe to spend” would silently encourage users to dip into their savings the moment runway feels tight. Excluding it surfaces saving as separate, protected money — the “set aside” framing reinforces that it’s not for spending. Same psychological pattern Apple, Wealthfront, and behavioral finance research consistently endorse.
Long thread of on-device review with Vik produced a list of follow-on questions, simplifications, and planned features. All captured in the math explorer’s expanded "Open Questions" section:
excludeCreditCardSideOfCcPayment is defense-in-depth, not a primary inclusion path — CC payments are already excluded by other filters.<Pressable onPress={toggleExpanded}> that wrapped the entire SafeToSpend card was swallowing ScrollView gestures. Restructured so only the hero header (amount + subtitle + chevron) lives inside the toggle Pressable; the drill-down section lives outside it.typicalDiscretionary now spans both checking and credit card outflows. A future $50 Uber on CC is still a real outflow — excluding CC swipes was conservative but dishonest. User 53's sample size jumped 16 → 160 transactions (10×) just by lifting the depository-only filter.typicalDiscretionary.windows map; each tab reads its own window's forecastAmount. perDayOfWeek is user-level (computed once); per-window projections are just different day-range sums of the same weekday averages.ExpandableBreakdownRow + ChildLine; replaced by the new DrillDownRow component.Three tightly-related lessons in one release. (1) Conservative scope choices read as hedging. Depository-only was “safe” but invisibly cut sample size 10× and pretended CC spending wasn't real. Own the scope or explain why you scoped down. (2) When the same math conceptually applies across UI slices (tabs), compute it once at the user level and project per-slice — don't force per-slice repeated compute or per-slice feature gaps. (3) Tappable = chevron. Typography-only affordance (underline) was too subtle; iOS users look for the right-chevron.
typical non-recurring spending and = if habits repeat, at payday.transactions.recurring_stream_id FK, populated by the recurrence detector. Lets downstream services filter recurring vs discretionary via WHERE recurring_stream_id IS NULL — no more error-prone normalized-name lookups.typical_discretionary_service.ts: up to 365 days of history, robust-mean-per-weekday capped at the 90th percentile, excludes non-discretionary primaries (INCOME, TRANSFER_IN/OUT, LOAN_PAYMENTS, BANK_FEES) and the CC-account side of CC bill payments. Adaptive lookback: min(365, daysOfHistoryAvailable), null when <60 days.calculate_safe_to_spend tool now surfaces typicalDiscretionary so the model can answer "what if I go out this weekend?" with a grounded baseline.Vik noticed two gaps. (1) "If I want to drill down into what those specifically are, I think I need a way to know that" — Credit card balances $7,021 and Bills before payday $14 were opaque; the data was on the response but not surfaced. (2) "What if you are buying things on your debit card that's not recurring? Do you think we have actually taken into account that at all?" — future impulse spending isn't in the runway formula. Industry norm says don't try to predict it; Vik said close the gap as honest context, using "at least one year of data to predict seasonality."
| Category | Captured? | How |
|---|---|---|
| Past debit / checking spending (cleared) | ✓ | spendableBalance reads availableBalance from Plaid |
| Past debit / checking spending (pending) | ✓ | Plaid availableBalance subtracts pending debits automatically |
| Past credit-card spending | ✓ | creditCardDebt = sum of current CC balances |
| Future recurring bills (rent, Netflix, gym) | ✓ | upcomingBills from recurring_streams (conf ≥ 0.3) |
| Future recurring income (paycheck) | ✓ | expectedIncome from income streams (conf ≥ 0.75) |
| Future non-recurring discretionary | Context (v4.6) | typicalDiscretionary forecast, shown as “Projection at payday” — NOT subtracted from runway |
When one threshold is doing two jobs, split it. v4.5 split confidence into anchor-reliability vs amount-reliability. v4.6 splits recurrence-match into "exclude from discretionary forecast" (FK-based, precise) vs "stream-level membership" (name-based, lenient). Both times the split produced a better answer. Also: typical spend stays as CONTEXT, not a formula input — runway is the user's contract with the number; don't bury a prediction inside it.
user_confirmed=true on its recurring_streams rowuser_confirmed bypasses the ≥0.6 ANCHOR bar (dates trusted), but NOT the ≥0.75 AMOUNT bar (one observation isn’t a forecast)payday_day_of_month column (1 or 15)GET /paycheck-confirmations/candidates + POST /paycheck-confirmationsReal-data reveal: the single confidence_score was gating TWO independent questions — “are the DATES reliable?” and “are the AMOUNTS reliable?” — and blended-low blocked the reliable-dates half. User: recent job change (1 paycheck, partial period) + paternity leave ending ($1,278–$3,197 variance). Both paths failed → 14-day fallback → negative runway on every tab. “Resolve it for me like I’m any other customer.”
When auto-detection is uncertain, let the user tap ground truth. Recognition tasks (pick from a list) reliably beat recall tasks (remember a date). Confidence scores are a signal, not a verdict — one user tap beats six noisy observations.
user_setting or inferredprofiles row is the ONLY source of truth — mobile writes via PUT /users/me/profile, no query-param overrides on SafeToSpendPaydayDivergenceBanner, isStale, divergenceWarning, legacyPaydaySource, thisMonth alias, legacy_data AI tool block, backfill ace command, “Last Paycheck” Settings rowlast_observed_datedate hydration off-by-one: May 2 was sliding into May 1 windows because DateTime.fromJSDate(d, { zone: tz }) converted UTC midnight to the user’s previous evening. New helper toUserLocalDate() normalizes everywhere“I think we’re complicating this a lot. Do you agree?” — after discovering the Settings UI showed Biweekly while the API quietly received monthly/1 via stale query params.
Complexity compounds silently. Every “edge case” branch added pre-PMF is a bug factory. Solve for the W2-salaried regular-paycheck user first; defer multiple income sources, job changes, thin Plaid history, and divergence detection.
Net −500+ LoC across backend + mobile. Zero eval fixture churn (distinguished-engineer review confirmed no fixture referenced any dropped field).
Checking − Bills before payday − Credit card balances + Income before payday = Runway− + = ÷) live in the label column; value column shows positive magnitudes color-coded by role“I know we’re starting with runway until payday. I think people are going to want to know how that was arrived at.” — Vik, 2026-04-23
Transparent derivation goes one layer deeper each iteration. v4 showed the math, v4.2 showed where $/day came from, v4.3 shows where runway came from. If you write = X anywhere in a financial UI, someone eventually asks = how?
min(..., untilPayday.amount) cap from v4.1amount = dailyBudget × daysInWindow with uniform perDayInWindow = dailyBudgetrunway ÷ days = $/day), then the per-tab math ($/day × days)v4.1’s cap had a side effect: perDayInWindow = amount / daysInWindow varied across tabs. User feedback: “This Month is taking $777/day for 9 days whereas Until Payday is taking $874/day times 8 days — doesn’t make sense.” Dropping the cap preserved a single daily rate everywhere.
amount formula: min(dailyBudget × daysInWindow, untilPayday.amount) for Today / This Week / This MonthOn-device test revealed the v4 formula collapsed amount to spendable − ccDebt whenever no bills/income fell inside the window. Wednesday-to-Sunday “This Week” returned the same number as Until Payday. User’s mental model: “$777/day × 5 days left in week = $3,887.” Fixed by switching to the daily-budget-allocation model.
“Mathematically honest” lost to “user-intuitive” on this one. Pay-cycle-daily-budget allocation > window-scoped sum.
SafeToSpendService; Smart Pulse / Morning Brief narratives kept the stricter 0.85 bardaysInWindow + perDayInWindow computed server-side"= $perDayInWindow/day × daysInWindow days"min(payCycle, dailyBudget×7) cap on This Week“If you want intuitive math, don’t lie about the cap.” Showing the honest per-window sum with a legible derivation beat a capped-but-opaque number. Consistency across tabs — even when redundant — felt more trustworthy than hidden logic.
Attempted to remove user-facing payday configuration entirely and rely only on stream detection. The thinking: “we can tell when you get paid from your bank data — why ask?”
For new users with thin Plaid history, stream detection needs weeks of data before it’s reliable. Until then, payday predictions were wrong — and “wrong by design” for new users is a dealbreaker. User control had to stay.
safe_to_spend_service.ts — AI Coach and Home now both delegate to it. The v1.3 duplication finally killed.Rest of Month (today → endOfMonth)daysUntilPayday = 0 correctly, no forward-roll to the next cycleConsolidation > abstraction. One implementation of the math with two callers is simpler than two implementations with a shared interface.
Feedback that the emotionally-relevant question is always “how much can I spend before the next paycheck lands?” — not “how much this calendar week?”. Paycheck cycle is the real rhythm of cash management.
loansExcluded[] to the response — mortgage / auto loan shown to the user but NOT subtracted from runwayPlaid doesn’t return loan transactions reliably, and a mortgage isn’t the kind of debt you pay off next month anyway. Surfacing it as context (“here are the big liabilities we’re aware of”) without contaminating the formula was the right line.
profiles rowMobile also still sent payday values as query params on every request — two sources of truth that would silently drift until v4.4. This version half-fixed the consistency problem.
spendableBalance − upcomingBills − creditCardDebt + expectedIncomecreditCards[] with per-card balanceUsers with rolling CC balances saw “Safe to Spend = $X” when they actually owed $X+ to their cards and were negative in net worth. Subtracting CC debt made the number honest at the cost of looking smaller — a deliberate tradeoff for financial accuracy over optimism.
AI Coach was computing its own daily budget; Home was computing a different one. Users asked the Coach “what can I spend today?” and got a different number than the hero card on Home. Hot-patched the AI Coach to match.
Two systems independently implementing the same math will drift. This became the motivation for extracting SafeToSpendService as the shared source of truth in v3.1.
dailyBudget = runway ÷ daysUntilPaydayThe shift from “here’s your runway” to “here’s what you can spend today and have runway left.” Debt-by-day, not windowed sum.
endOf('week'), endOf('month'))First lesson in what would become a recurring theme: timezone and date semantics are the hardest part of a cashflow app. A date is not an instant.
Today, This Week, This Monthamount = spendableBalance − upcomingBills + expectedIncomeMinimum viable affordance: give the user one honest number they can glance at. Most cashflow apps stop here. Quickly became clear that “your balance” is not the same as “what you can safely spend” — pending bills and upcoming rent change the answer dramatically.