Markdown companion: docs/SAFE_TO_SPEND_TYPICAL_SPENDING_SPEC.md
How we compute the Spending habits Money Out term on Home’s Safe to Spend card (v5.4: subtracted from the runway, tagged “estimated” — formerly the context-only “if your habits repeat” projection). Walks the actual algorithm, the actual filters, and a worked example from user 53’s real transaction history.
SafeToSpend’s Runway line tells you how much cash is available today after settling all committed liabilities (known bills, CC balances) and adding expected income up to your next payday. Runway answers: "if nothing non-recurring happens, where do I land?"
Real life isn’t that clean. Users swipe cards at restaurants, hit Target, grab Ubers. Those outflows don’t show up in Runway because they aren’t recurring bills. The Typical Spending Forecast projects that unplanned spending from the user’s actual history — checking/debit accounts only since v5.4 — so the accordion can show “you’d likely have $X left by {date}” as a realistic projection.
moneyOut.habits), exactly like the essentials term: Safe to Spend is what’s truly free even if your habits repeat. Two scope rules changed with the promotion: the sample is checking/debit-funded only, like essentials (the v4.7 credit-card broadening below is historical — a card swipe never leaves checking before payday; the ccDue repayment ladder already carries it) and it excludes the essentials whitelist (groceries/gas belong to the essentials term; the old sample double-counted them, which made the retired projection line overly pessimistic). The worked example below predates v5.4 — the robust-mean mechanics it illustrates are unchanged, but its sample includes credit-card and essentials transactions a v5.4 sample would exclude.
Only transactions that match all of these rules in the last up to 365 days (adaptive: min(365, daysOfHistoryAvailable); returns null below 60 days):
user_id = current_usertransaction_type = 'debit' (outflow from the account)recurring_stream_id IS NULL — not part of a detected recurring stream (so Netflix, rent, Amazon Prime are excluded; that stuff is already in the Runway’s “Bills before payday” line)is_pending = false (cleared only)bank_accounts.account_type = 'depository' AND account_subtype = 'checking' (v5.4) — checking/debit only, like essentials and the runway itself. Credit cards are out (the ccDue ladder carries them); savings/prepaid/PayPal subtypes are out (set-aside, never draw on the runway); loans are outplaid_category_detailed NOT LIKE any ESSENTIALS_PFC_PREFIXES prefix (v5.4, NULL categories kept) — necessities belong to the essentials term; counting them here double-subtractsexcludeSupersededPending)categories.parent_code NOT IN ('INCOME','TRANSFER_IN','TRANSFER_OUT','LOAN_PAYMENTS','BANK_FEES') — these are never discretionary spendingexcludeCreditCardSideOfCcPayment)lookbackDays = min(365, today − oldest + 1). If < 60, return null (the UI renders “we’ll show your pattern after a few more weeks”).
transaction_date and amount.
dailySpend: Map<ISODate, TotalSpendThatDay>. The map only stores keys for days that have at least one qualifying transaction — zero-spend days have no entry at this stage. This is just an intermediate data structure; zero-spend days are added back as zeros in the next step.
dailySpend with ?? 0 (defaulting to zero when the date isn't in the map) and push it into a bucket keyed by weekday. So zero-spend days ARE included in the per-weekday samples — they just enter as zeros at this step rather than being stored in the intermediate map.
floor(n × 0.9), take that value as the cap, replace any observation above the cap with the cap value, then take the plain mean. Below 4 samples for a weekday → return 0 (refuse to publish noise).
windowEnd (Today = today; This Week = end-of-ISO-week-in-user-tz; This Month = end-of-month; Until Payday = nextPayday − 1 day, clamped to today — v5.4: matches the STS untilPayday tab so the metadata window covers exactly the days the ledger subtracts). For each tab: walk today through windowEnd inclusive, sum the perDayOfWeek[weekday] value for each day in the walk. That sum is forecastAmount.
Previous iteration used median per weekday. It breaks on sparse-data users: if Monday has 14 samples with 10 zeros and 4 non-zero spend days, the median is zero. The forecast collapses to zero. Wrong — the user does sometimes spend on Mondays.
Plain mean is too fragile the other way: one $4,210 Thursday pulls the Thursday average up by $4,210 / n. A single Costco run warps the whole forecast.
Robust mean with p90 cap gets both: zero-heavy weekdays still produce non-zero estimates (because the non-zeros count at full weight), and outliers get pulled down to the 90th-percentile value. Simple enough to explain, strong enough to survive real user data.
Known limitation: day-of-week seasonality only. Month-of-year patterns (December holiday spike, summer travel) need multi-year data to denoise. We have at most 1 year. When we have 3+, we’ll layer month-of-year as a second signal.
Lookback: 101 days (oldest transaction is 2026-01-13; today is 2026-04-23; capped at 365).
Sample size after filters: 160 non-recurring discretionary transactions.
Observed days (days with any qualifying spend): 67 of 101 ≈ 66%.
From the filtered query, grouping by date:
| Date | Weekday | Daily total | Tx count |
|---|---|---|---|
| 2026-04-22 | Wed | $44.89 | 1 |
| 2026-04-21 | Tue | $201.07 | 3 |
| 2026-04-19 | Sun | $1,944.37 | 5 |
| 2026-04-17 | Fri | $2,210.64 | 2 |
| 2026-04-16 | Thu | $15.00 | 1 |
| 2026-04-15 | Wed | $133.95 | 3 |
| 2026-04-13 | Mon | $77.88 | 4 |
| 2026-04-10 | Fri | $16,979.10 | 6 |
| 2026-04-08 | Wed | $54.76 | 3 |
| 2026-04-07 | Tue | $193.08 | 4 |
| … (57 more rows) |
Every day in the 101-day lookback is pushed into exactly one weekday bucket with its spend (zero if no qualifying transactions). Example — all 15 Thursdays in the window:
| Date | Spend |
|---|---|
| 2026-01-15 | $7.25 |
| 2026-01-22 | $0.00 |
| 2026-01-29 | $69.38 |
| 2026-02-05 | $17.04 |
| 2026-02-12 | $37.31 |
| 2026-02-19 | $357.00 |
| 2026-02-26 | $166.00 |
| 2026-03-05 | $11.95 |
| 2026-03-12 | $4,180.56 |
| 2026-03-19 | $0.00 |
| 2026-03-26 | $957.12 |
| 2026-04-02 | $4,210.00 |
| 2026-04-09 | $0.00 |
| 2026-04-16 | $15.00 |
| 2026-04-23 | $0.00 (today) |
Sort the 15 samples ascending:
[0, 0, 0, 0, 7.25, 11.95, 15, 17.04, 37.31, 69.38, 166, 357, 957.12, 4180.56, 4210]
90th-percentile index: floor(15 × 0.9) = floor(13.5) = 13. sorted[13] = 4180.56. That’s the cap.
Apply the cap (replace values above $4,180.56): the single $4,210 outlier gets pulled down to $4,180.56. Everything else unchanged.
capped = [0, 0, 0, 0, 7.25, 11.95, 15, 17.04, 37.31, 69.38, 166, 357, 957.12, 4180.56, 4180.56]
Mean: sum(capped) / 15 = 9999.17 / 15 = $666.61. That’s the Thursday number you see on the Typical Spending card.
| Weekday | Sample count | Raw mean | P90 cap | Robust mean (shipped) |
|---|---|---|---|---|
| Monday | 14 | $75.00 | $226.33 | $72.15 |
| Tuesday | 15 | $160.12 | $215.24 | $120.56 |
| Wednesday | 15 | $227.49 | $133.95 | $59.96 |
| Thursday | 15 | $716.33 | $4,180.56 | $666.61 |
| Friday | 14 | $1,516.38 | $2,210.64 | $461.49 |
| Saturday | 14 | $0.00 | $0.00 | $0.00 |
| Sunday | 14 | $261.51 | $312.17 | $146.30 |
For each SafeToSpend tab, walk today through the tab’s windowEnd inclusive, sum perDayOfWeek[weekday] for every day walked. Today is Thursday April 23, 2026.
Why Until Payday > This Month. Until Payday’s 9-day window stretches past end-of-month into May 1 (Friday — your next payday). Friday is a heavy-spend weekday ($461.49). That single extra Friday explains why Until Payday is $461.49 more than This Month, even though both end within 24 hours of each other.
The number users see as "You’d likely have $X left at payday (May 1)" is:
projection = runway − typicalDiscretionary.windows[selectedTab].forecastAmount
For user 53 today:
| Tab | Runway | Typical spending | You’d have left |
|---|---|---|---|
| Today | $6,795.80 | $666.61 | $6,129.19 |
| This Week | $6,795.80 | $1,274.40 | $5,521.40 |
| This Month | $6,795.80 | $2,193.68 | $4,602.12 |
| Until Payday | $6,795.80 | $2,655.17 | $4,140.63 |
Runway stays constant across tabs — it’s a snapshot of the moment. What changes is how much typical spending gets subtracted, because each tab projects over a different length of time.
| Excluded | Reason |
|---|---|
Recurring streams (via recurring_stream_id IS NOT NULL) | Already in Runway’s “Bills before payday” line — would double-count |
Transfers (TRANSFER_IN, TRANSFER_OUT) | Between your own accounts; not net spending |
Loan payments (LOAN_PAYMENTS parent) | Mortgage, auto, student loan — committed, not discretionary. Some are recurring (already excluded) but this also catches one-off paydowns |
Bank fees (BANK_FEES) | Interest, overdraft, maintenance — not user-chosen spending |
Income (INCOME parent, but also filtered by transaction_type = 'debit') | Inflows aren’t spending |
| CC-account side of a CC bill payment | When you pay a CC from checking, Plaid emits two transactions (one on each account). The checking side is already a LOAN_PAYMENTS outflow; the CC side is a balance credit. Excluded so we don’t count the same event twice |
| Pending transactions | Amount/date could still change. Once cleared they flow back into the sample |
| Loan accounts | Plaid reports them but they’re not discretionary spend sources |
$666 felt high. Here’s why: your monthly rent ($4,200/mo on Bilt Rewards card) is misclassified as discretionary because the recurrence detector hasn’t caught it yet. Bilt has only fired twice in your 102-day lookback (Mar 12, Apr 2 — about 21 days apart, not exactly biweekly or monthly), so the detector hasn’t built a stream for it. With recurring_stream_id IS NULL on those transactions, they fall into typical-discretionary’s sample.
| Date | Description | Amount | Should be? |
|---|---|---|---|
| 2026-03-12 Thu | BILT CARD HOUSING | $4,170.55 | Recurring bill (rent) |
| 2026-04-02 Thu | BILT CARD HOUSING | $4,200.00 | Recurring bill (rent) |
Even with the p90 cap, both contribute heavily: cap = sorted[13] = $4,180.56, so Apr 2’s $4,210 gets clamped to $4,180.56 but Mar 12’s $4,170.55 is below the cap and contributes its full value.
If BILT were classified as a recurring bill:
recurring_stream_id != NULL and would be EXCLUDED from typical-discretionary's sampleMirror of the existing “Confirm your paycheck” pattern. User taps a transaction in their history → it gets user_confirmed=true on a recurring_streams row → bypasses the detector's occurrence-count threshold. Same plumbing exists; just needs UI and the bill-side flag. Documented as planned post-MVP feature.
Any user with thin Plaid history (less than ~3 months), irregular-cadence bills (quarterly insurance, semiannual property tax), or just-started bills (new gym, first month of rent on a new card) will see the same misclassification. The typical-discretionary bucket over-counts in those cases, projection-at-payday under-counts what they’ll have left. Resolved by Confirm-a-recurring-bill once we ship it.
apps/backend/app/services/typical_discretionary_service.ts — the whole algorithm. computeTypicalDiscretionary() is the entry point.apps/backend/app/services/transaction_filters.ts — the NON_DISCRETIONARY_PRIMARIES constant, excludeNonDiscretionary(), excludeCreditCardSideOfCcPayment(), excludeSupersededPending() helpers. Note: excludeCreditCardSideOfCcPayment is defense-in-depth in this query — the CC-account side of CC bill payments is already filtered out by transaction_type = 'debit' (it's a credit on the CC), and the checking-account side is filtered out by the LOAN_PAYMENTS parent-code exclusion. The explicit filter is redundant but cheap; it protects against future regressions if filter 1 or 3 ever loosens.apps/backend/app/services/recurrence_detection_service.ts — writes transactions.recurring_stream_id on the detector’s upsert pass. That’s the FK the service uses to exclude recurring spend.apps/backend/app/services/safe_to_spend_service.ts — calls computeTypicalDiscretionary, attaches result to the API response.apps/mobile/src/components/dashboard/SafeToSpend.tsx — renders the Projection block with the per-day cards.
Single caller, single source of truth. Both the Home dashboard and the AI Coach’s calculate_safe_to_spend tool read the same numbers from the same service. If a user asks the Coach “what would I typically spend by payday?” the answer comes from exactly this math, not a second implementation.