SKIP_AUTH=true in apps/mobile/app/_layout.tsx bypasses onboarding and routes straight to (tabs)/home. Default user is dev-bypass user ID 53 (all data belongs to this user in dev).GET /api/dashboard with Cognito access tokenAuthorization: Bearer {access_token} from Expo SecureStore. If the token is expired (>1h old), a 401 interceptor refreshes via the 30-day refresh token transparently before retrying the request. Dev mode skips logout() on 401 so the auth cache isn't wiped during local work.aws-jwt-verify in auth_middlewarecognito_sub and resolves to internal users row. Dev bypass: when NODE_ENV !== 'production', middleware short-circuits to user ID 53 regardless of token — ensures dev data stays consistent for a single user.GET /api/dashboard → UsersController.dashboard()apps/backend/app/controllers/users_controller.ts:114-145. Handler authenticates via Adonis auth.user!, then fans out a single Promise.all() with three independent queries (accounts, transactions, bills). All three run concurrently — the slowest one determines total latency.BankAccount.query().where('userId', user.id).where('isActive', true). Returns depository, credit, and loan rows. Each row includes availableBalance, currentBalance, accountType, accountSubtype, mask, encrypted officialName (auto-decrypted by Lucid computed property).Transaction.query().where('userId', user.id).orderBy('transactionDate', 'desc').preload('merchant').preload('category').limit(10). Preloads nested merchant + category rows so the mobile side has display name, icon, and color in one payload. Recent-transactions list on Home renders from this directly.RecurringStream.query().where('userId', user.id).where('isActive', true).whereIn('streamType', ['bill','subscription']).where('predictedNextDate', '>=', today).orderBy('predictedNextDate', 'asc').preload('bankAccount').limit(5). Reads directly from recurring_streams (the sole source of truth as of 2026-04-22). Rows are mapped to a slim shape so the dashboard field matches what the Bill Predictions flow returns from GET /predictions/bills.totalBalance = accounts.reduce((sum, a) => sum + a.availableBalance, 0). Final JSON: { totalBalance, accountCount, accounts[], recentTransactions[], upcomingBills[] }. toJSON() invokes Lucid's serialization: encrypted fields decrypted automatically; fields marked serializeAs: null (SSN, Plaid access_token, etc.) are excluded from the wire payload.['dashboard'] with 60s staleTime. Home immediately issues parallel follow-ups for the features that have their own endpoints: GET /predictions/safe-to-spend?timezone=… (SafeToSpend), GET /predictions/bills (Upcoming Bills — streams-based source of truth). Each of those is its own spec on this page.GET /api/dashboard → { totalBalance: number, accountCount: number, accounts: BankAccount[], recentTransactions: Transaction[10], upcomingBills: UpcomingBill[5] }. Auth required. Bouncer policies enforce user_id === authenticatedUserId on every preloaded relation. upcomingBills[] items carry { id, predictedAmount, predictedDate, confidence, recurrencePattern, merchant: { name }, accountName, accountType } — same shape as GET /predictions/bills so mobile can use one component. No pagination — the limits are hard-coded (10 transactions, 5 bills) because this is a home screen summary.
Promise.all() over three queries. Target p95 < 500ms. If any single query exceeds ~200ms we treat it as an N+1 or missing-index bug, not a design tradeoff. Home makes 3 parallel follow-ups after this call (SafeToSpend, Bills, and the notifications inbox badge) — all run off the same auth middleware cache so JWT re-verification is <1ms.
user_id = auth.user.id. Never trust query params for user scope.officialName, mask) auto-decrypt via Lucid computed properties. Plaid access tokens never leave the backend (serializeAs: null).redactForLogging() — no PII in CloudWatch.
transactions/get date range with NO cursor, so a late-posting transaction or a multi-day outage fell out of the 7-day window forever while newer rows kept landing. The field case: a May 29 payroll deposit missing while June 4/8 synced fine. Dedup on plaid_transaction_id makes the wider re-pull idempotent; cursor-based /transactions/sync is the eventual fix). Transactions land with their category_id assigned per the Plaid Sync Category Resolution Priority (see the block below). Also triggered by pull-to-refresh and Plaid webhooks.SyncDailyTransactionsJob per connectiontransactions-sync, concurrency: 5, retries: 3, backoff: 2s exponentialplaid_merchant_id when available, else by name. Stores Plaid primary/detailed codes on merchants row.GENERAL_SERVICES_OTHER / ENTERTAINMENT_OTHER / PERSONAL_CARE_OTHER / GENERAL_MERCHANDISE_GIFTS_AND_NOVELTIES / null AND have no cached ai_category_id are batch-sent to Haiku (25 per call). Output constrained to SOFTWARE_AND_TECH_ALL / SUBSCRIPTIONS_* / KEEP_PLAID. Result persists to merchants.ai_category_id — one call per unique merchant, reused forever.category_id via priority chainplaid_transaction_id as unique key. v5.3 poison-row hardening: every varchar(255)-bound field (description, merchant name, plaid_category_detailed, pending_transaction_id) is clamped to 255 chars before insert, and each row upsert is individually try/caught (skip + log by opaque Plaid id, never the memo content) — pre-fix, ONE over-long bank memo threw mid-loop and silently dropped every transaction after it on EVERY sync (the second root cause of the missing-payroll field bug). Category resolved via merchant rule → merchants.ai_category_id → Plaid detailed (see priority chain block below); sync writes categorySource = 'rule' | 'ai' | 'plaid'. The fourth value, 'user', is set ONLY by the direct per-transaction edit (PATCH /transactions/:id) and arms the clobber-guard: before upserting, sync fetches the existing row and if categorySource==='user' it merges every field EXCEPT category_id/category_source — a user-corrected category is NEVER clobbered on re-sync (invariant, LIVE end-to-end since the category-correction loop shipped 2026-06-12). Also stores pending_transaction_id from Plaid so the pending-dedup filter can fire later at read time.GeneratePredictionsJobpredictions. Recalculates bill predictions and spending forecasts based on new data. (Payday predictions generation removed 2026-04-22 — unused.)POST /api/webhooks/plaid → ProcessWebhookEventJob → selective account refreshcategory_id is resolved by walking three tiers in order. The first tier that produces a non-null category wins. Applied in step 4c above. Guard above all tiers: if the existing row already has categorySource='user' (a direct per-transaction edit), sync skips category resolution entirely for that row — the user's choice always wins. categorySource enum: 'plaid' | 'rule' | 'ai' | 'user' ('rule' = merchant-override tier below; 'user' = direct per-transaction edit via PATCH /transactions/:id).
merchant_category_overrides row (unique per user+merchant) — written when the user recategorizes a transaction and picks "Always for <merchant>" (PATCH /transactions/:id with applyToMerchant: true → updateOrCreate, last write wins). Applies to ALL future transactions from that merchant for that user. categorySource='rule'. Shipped end-to-end 2026-06-12: tap any row in the transactions list → category picker sheet (leaf categories grouped under primaries; bare primaries not selectable — the backend rejects them since the spending exclusion checks parent_code) → "Just this once" (edits only that row, categorySource='user') vs "Always for <merchant>" (also writes this rule). The edited row itself is protected by the categorySource='user' clobber-guard above; this rule tier is what carries the correction FORWARD to new transactions.
merchants.ai_category_id — set by Claude Haiku on a prior sync when Plaid's code was ambiguous (step 4b). categorySource='ai'. One AI call per unique merchant, reused forever. Ambiguous Plaid codes today: GENERAL_SERVICES_OTHER, ENTERTAINMENT_OTHER, PERSONAL_CARE_OTHER, GENERAL_MERCHANDISE_GIFTS_AND_NOVELTIES, null. Widen this set based on DB evidence — not blind speculation.
categorySource='plaid'. Handles confident cases (coffee, gas, groceries) with zero AI cost. Used when no user override and no AI cache.
initialMessage → auto-send via useEffect → SSE stream via XMLHttpRequest → Tide Coach tool loop over AWS Bedrock (Claude Sonnet 4.6) → persist both messages via BullMQ → export the turn as one trace to Arize (when enabled). Multi-turn, tool-using, with a strict alternating-role contract that Bedrock requires and this flow enforces.POST /api/chats to create the chat row, then router.push with initialMessagecreateChat returns { id }. App navigates to /chat/[id] with the first message as a URL param. Chat screen's useEffect on mount reads initialMessage and fires sendMessage exactly once — same path as any subsequent user turn. This unifies "first message" and "nth message" into one code path.XMLHttpRequest (not fetch)fetch() does NOT expose response.body as a ReadableStream — so streaming via fetch is impossible. apps/mobile/src/services/sse-client.ts uses XMLHttpRequest with onprogress to read incremental bytes, parses event:/data: lines, and re-emits events as they arrive. This is load-bearing — see LEARNINGS.md "Streaming not supported".POST /api/chats/:id/messages → ChatsController.sendMessage()apps/backend/app/controllers/chats_controller.ts:76-291. Bouncer: ChatPolicy.edit(chat) enforces ownership. Validator: sendMessageValidator (non-empty content). If isBedrockAvailable() returns false (missing AWS creds), returns 503. First turn also sets chat.title to the first 100 chars of the user message.chat_messages via ChatMessage.create() — NOT queued. Why: the next turn's history query would otherwise see two consecutive user roles because the queued assistant response from this turn isn't persisted yet. Content is AES-256 encrypted at rest. Role = 'user'.chatConfig.maxContextMessages messages for this chat (default 20, covers ~10 turns). Reverses to chronological. Decrypts each with getDecryptedContent(). Filters out blank messages — Bedrock's ConverseStream API rejects empty text ContentBlocks, which caused a multi-turn crash (see LEARNINGS.md 2026-04-18).'user'. This is defensive — the synchronous user persistence in step 5 usually prevents the issue, but the queue fallback path can still produce gaps.const intentPromise = Promise.resolve('unknown'). Intent classification is currently turned OFF — it consumed a separate Haiku call per message and competed with the chat stream for Bedrock quota, causing throttle errors during multi-turn sessions (see commit 32cd3ad). Re-enable once AWS quota is raised. The persistence path still writes intentClassified to chat_messages for future filtering.Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no (prevents nginx/CDN buffering). Local sendSSE(event, data) helper writes event: X\ndata: JSON\n\n frames. Builds system prompt via buildCoachSystemPrompt(user.fullName) and instantiates new ToolExecutor(user.id) against a read-only DB connection.runCoachTurn (services/ai/coach_turn.ts) — the Coach's single entry point, shared by the app and the scenario runnerrunCoachLoop (services/ai/coach_loop.ts) against a CoachModelClient — bedrockCoachClient for the app, createAnthropicCoachClient for scheduled eval runs (same model id, derived from the Bedrock id; same prompt, tools and temperature). Model: global.anthropic.claude-sonnet-4-6 (Tide Coach). Tool loop: max MAX_TOOL_ITERATIONS = 10 iterations. Each iteration: Bedrock streams text chunks + possibly a tool use block; controller relays text to SSE as event: text_delta; if the model requests a tool, controller pauses, runs it via ToolExecutor, feeds the result back into the next Converse round. Throttle handling: ThrottlingException triggers exponential backoff retry (1s/2s/4s).query_bank_accounts now returns each account’s lastSyncedAt so the Coach can say when data is stale. Round-2 fix: each Safe-to-Spend window in the Coach’s projection carries cardPaymentsHeldBack[] (already subtracted) and cardPaymentsAfterWindow[] (not in the number), computed by splitCardPayments(), so the Coach never re-derives coverage from dates. Round-1 tool fixes (2026-10-04, phase 5c–5e): query_transactions and the insight tools apply excludeSupersededPending (a pending charge and its posted twin are one charge); query_bills_predictions returns bills[] (every occurrence), cardPayments[] (the STS ccObligations, same as Home’s Recurring Expenses) and subscriptionsBilledToCards[]; get_financial_insights runs the pure rules in coach_insights.ts (expenses exclude transfers/income/card payments; habitual-merchant-aware anomalies; repeated transfers; likelyEnded streams); query_payday_predictions adds paydays[] (the whole schedule in the window). The tool loop inserts a paragraph break between text written before and after a tool call. Headliners: calculate_safe_to_spend (delegates to shared safe_to_spend_service.ts — same numbers as the SafeToSpend card), get_spending_by_category (2026-06-12: rebuilt on the shared spending-math core — spending_query.ts + spending_insights_math.ts + pure projectSpendingForCoach() — same numbers as the Spending tab donut, windows in the user's profiles.timezone), query_transactions, query_bills_predictions, query_payday_predictions. F0.1 / Epic 0 (2026-06): calculate_safe_to_spend's result is shaped by the pure projectSafeToSpendForCoach() projection so it surfaces EVERY field the Home card renders — per-window bills[]/income[], the full resolved payday envelope, the accountsIncluded/setAsideAccounts/creditCards/loansExcluded arrays, lastSyncedAt, and typicalDiscretionary.perDayOfWeek. The Coach answers "which bill is due first?" or "which account has the most?" without a second tool call. Each tool call runs with Bouncer policies enforced on the read-only connection. Tool input/output appended to accumulatedTools for persistence. Each tool call is emitted as event: tool_use over SSE so mobile can render a "thinking about X" chip.event: text_delta frames arrive. Tool use frames render as small chips ("Checking your spending…"). On event: done the bubble freezes. On network drop or malformed frame, the client keeps whatever partial text it has rather than erasing — mirrors server-side partial persistence.persistAssistantMessage(accumulatedText, accumulatedTools) dispatches PersistChatMessageJob to queue chat-messages (30-day failure retention — do not reduce). Job writes { chatId, role: 'assistant', content, intentClassified, toolsUsed (JSON), modelUsed: SONNET_MODEL_LABEL } — the label is derived from SONNET_MODEL_ID (strips the us.anthropic. prefix and dated -v1:0 suffix), resolving to 'claude-sonnet-4-6' — with AES-256 encrypted content. If queue dispatch throws (Redis down), falls back to a synchronous ChatMessage.create(). Both success and error paths call this — partial assistant responses are persisted even when streaming errors out mid-flight.event: done and closes the SSE stream{ intent } (the classifier is disabled, so this is 'unknown'). Telemetry (totalLatencyMs, firstTokenMs, tools called, and — since 2026-09-26 — input_tokens/output_tokens summed across the tool loop from the stream's metadata events, cached prompt tokens included) is written server-side to the ai_traces table, not sent in the done frame. nodeRes.end() closes the HTTP response. On error path: event: error with a user-facing message, then done, then persist whatever partial text accumulated.sendMessage via startCoachTurn() (app/services/tracing/coach_tracing.ts) before the stream starts; passed into runCoachTurn. Span tree: one AGENT span coach.turn (question, answer, chat id as session, rendered date + timezone, prompt-version fingerprint) → one LLM span coach.llm per trip round the tool loop (messages sent incl. system prompt, tool calls requested, stop reason, prompt/completion/cache tokens) and one TOOL span coach.tool per tool call (arguments + the full JSON output — the only record of what the Coach actually saw). Masked before export by createTraceMasker (trace_masking.ts): name, email, account/routing numbers, account masks, Plaid ids, long digit runs and opaque tokens are replaced; amounts, dates and merchants are kept so answers can be graded. Content is attached only for users in TRACE_CONTENT_USER_IDS; everyone else is traced structurally (tool names, tokens, timings). Exported by a BatchSpanProcessor registered in start/tracing.ts — off unless ARIZE_ENABLED=true with ARIZE_SPACE_ID + ARIZE_API_KEY; when off every span is a no-op.global.anthropic.claude-sonnet-4-6. Intent classifier (disabled today, reserved for quota recovery): us.anthropic.claude-haiku-4-5-20251001-v1:0. Access via AWS Bedrock Converse / ConverseStream APIs. Custom HTTP strategy is used for Bedrock AgentCore JWT auth (AWS SDK cannot — see CLAUDE.md).
chatConfig.maxContextMessages = 20 (chronological). Blank messages are filtered out before sending to Bedrock (empty text blocks crash ConverseStream). Alternating user/assistant roles enforced — defensive cleanup handles the rare queue-lag gap.
MAX_TOOL_ITERATIONS = 10 per turn. All tools run against a read-only DB connection. Bouncer policies still enforced: user_id === authenticatedUserId at every query. calculate_safe_to_spend delegates to the shared safe_to_spend_service.ts — Home card and AI Coach return byte-for-byte identical numbers. Home parity (Epic 0): the tool result is shaped by the pure projectSafeToSpendForCoach() projection so every field the Home card renders is reachable in ONE call (no follow-up tool call, no Coach-side arithmetic); a "Home parity principle" system-prompt rule (F0.2) forbids re-computing these numbers, and the coach_safe_to_spend_parity.spec.ts unit test (F0.4) fails CI if a field is ever dropped.
XMLHttpRequest + onprogress, not fetch(). RN's fetch does not expose response.body as a ReadableStream. See apps/mobile/src/services/sse-client.ts. Server writes text/event-stream with X-Accel-Buffering: no to disable CDN buffering.
chat-messages queue with 30-day failure retention (never reduce). Synchronous fallback if queue dispatch fails. Partial responses persisted even on streaming errors.
https://otlp.arize.com/v1/traces), project ARIZE_PROJECT_NAME (default tide-coach). Span tree AGENT → LLM (per loop step) + TOOL (per call). Kept: amounts, dates, merchants. Masked: name, email, account/routing numbers, masks, Plaid ids, digit runs ≥8, opaque tokens. Content only for allowlisted users (TRACE_CONTENT_USER_IDS, dev: 53). Built by hand, not with auto-instrumentation (undocumented for ConverseStream, and it cannot see tools). Purpose: every Coach answer can be graded against exactly what the Coach was shown — see docs/EVALS_STRATEGY.md §7.
2e33ff4. Intent classification was disabled to free quota for the chat stream — re-enable only after AWS quota increase. Circuit breaker on AI service: 5 failures → open 30s. All AI calls use the shared circuit breaker + retry wrapper per CLAUDE.md.
event: error + doneGET /predictions/bills auto-runs the 6-stage RecurrenceDetectionService when streams are stale (>24h) or missing — bypassing any background-job dependency. Streams are persisted to recurring_streams (18 columns incl. predicted_next_amount, predicted_next_date, confidence_score, and the STS v5 category MECE bucket) through the single shared detectAndPersistStreams() path used by all three triggers (on-demand, post-sync, BullMQ job). v5.2 display presentation (presentUpcomingOccurrence, pure + unit-tested): the old predictedNextDate ≥ today SQL bound silently dropped slipped predictions (a stale claude.ai row made an ACTIVE card subscription look "never detected"); now STALE streams (>0.75× period overdue) are SUPPRESSED — never re-asserted — and slipped-but-active dates are ADVANCED into the window via expandOccurrences, marked dateBasis: 'advanced' ("date estimated" in the UI). Consumers: Recurring Expenses card + See All screen (v5.2 — account-scope FilterPills: "Checking & debit" default = depository streams + display-only card-payment rows, counted in STS; "Credit cards" = card-billed recurring charges, DISPLAY-ONLY with an explainer — the card repayment already carries them, so they are never subtracted from Safe to Spend; one shared InfoTip copy map sts-copy.ts keeps the trust copy from forking), Safe to Spend ledger (depository-gated, v5.2), AI Coach query_bills_predictions tool (depository-gated, v5.2).GET /predictions/bills?daysAhead=60&limit=25daysAhead = 60, limit = 25. Auth token required. React Query caches under ['bills', daysAhead, limit] with 60s staleTime.recurring_streamsapps/backend/app/controllers/predictions_controller.ts:17-106. Reads the first active stream for this user. needsRefresh = !existingStreams || (updatedAt older than 24h). If fresh, skip to step 8. If stale or none, run detection in-request (on-demand, NOT background).normalizeDescription() strips: payment processor prefixes (SQ*, PAYPAL*, TST*, SP*), phone numbers (10+ digit runs with punctuation), trailing #codes and REF:codes, corporate suffixes (.COM, INC, LLC, LTD, CORP), and city/state patterns. Result: "NETFLIX.COM #4321 LOS GATOS CA" → "NETFLIX".bank_account_id before any merchant/description matching — the same payer into two accounts is two streams. The field case that forced this: a direct-deposit split lands one employer's payroll into 3 checking accounts on the same days; merged account-agnostic groups produce same-day duplicate intervals (0, 0, 14, 0, 0, 17…) whose CV fails period detection, so the paycheck was never detected at all. Accepted cost: moving a subscription between cards resets its observation count (same class as a renamed descriptor). Within each account: groups by merchant_id when available, else by normalized name; Dice coefficient 0.75 threshold merges near-duplicate names (e.g., "SPOTIFY USA" vs "SPOTIFY"). Each group carries { key, merchantId, normalizedName, transactions[] }. Groups with fewer than 2 transactions are dropped — need at least 2 occurrences to detect recurrence.KNOWN_PERIODS: weekly=7 (±2), biweekly=14 (±3), semi_monthly=15 (±3), monthly=30 (±5), quarterly=91 (±10), annual=365 (±20). First period inside tolerance wins. Groups with no matching period are dropped.anchorDay = modal day-of-period (e.g., day 1 for monthly rent, day 15 for mid-month). Predicted next date = last observed + detected period, snapped to the anchor day when applicable. averageAmount = mean; lastAmount = most recent; predictedNextAmount = lastAmount (more recent > long-run average for subscriptions that got price hikes).computeConfidence): (a) interval regularity 35% (1 − intervalCV/0.5), (b) amount consistency 25% (1 − amountCV/0.3), (c) occurrence count 20% ((count−2)/6, capped at 1), (d) recency 20% (1.0 if last occurrence within 1.5× the detected period, stepping 0.5 → 0.2 → 0 as the gap grows). Streams below MIN_CONFIDENCE = 0.3 are dropped. streamType: positive-amount runs tagged 'income' (paychecks, direct deposits); negative amounts get 'bill' or 'subscription' based on amount stability and merchant name heuristics.classifyStreamCategory(): majority vote over the member transactions' Plaid personal_finance_category detailed codes, direction-gated. Debit prefixes: LOAN_PAYMENTS_CREDIT_CARD* → cc_payment, LOAN_PAYMENTS_* → loan, RENT_AND_UTILITIES_* → utility, TRANSFER_OUT_* → planned_savings; credit prefixes: INCOME_* → income, TRANSFER_IN_* → transfer_in. A label wins only with a strict majority of PFC-bearing transactions; otherwise fall back to the legacy cadence heuristic (credit → income; debit <$50 monthly/annual → subscription; else bill). This is the partition that keeps every Money Out dollar in exactly one STS v5 bucket — a checking→card autopay is cc_payment, never a bill.detectAndPersistStreams()user_confirmed streams are never deactivated — a confirmed paycheck with <3 observations must survive every refresh). v5.3 ownership contract: confirmed streams OWN their member transactions — their FK links are never cleared, and a detected group whose members majority-belong to a confirmed stream is SKIPPED (the detector re-deriving the same deposits under a different frequency key — 2 paychecks 14 days apart read as biweekly next to the confirmed semi_monthly slice — would otherwise create an active unconfirmed duplicate that double-counts the paycheck in Money In; partial overlaps never steal owned members). Clears and re-points transactions.recurring_stream_id for detector-owned rows, then upserts each detected stream by the (userId, normalizedMerchantName, frequency, bankAccountId) composite key (v5.3 — bank_account_id is part of stream IDENTITY, matching the account-scoped grouping and the paycheck-confirmation upsert; without it, two confirmed slices of a multi-account paycheck sharing a normalized name would smear one account's stats onto an arbitrary sibling row) with category. Transactional so concurrent Home calls never observe an all-inactive interim state (would flash resolvePayday() to its fallback mid-render). Same path for all three triggers: this controller, the post-sync background pass, and the BullMQ GeneratePredictionsJob.where streamType IN ('bill', 'subscription') AND (category IS NULL OR category NOT IN ('cc_payment', 'planned_savings')) AND isActive = true AND predictedNextDate BETWEEN today AND today+daysAhead — card autopays are owned by the STS CC ladder (showing them here would read as a double charge) and planned savings live in the STS breakdown. Loans pass through (their legacy streamType is bill). Response rows carry category + server-computed confidenceBand. Preloads bankAccount for the "Chase • Checking ••4321" label. Sorted by predictedNextDate ASC. Income streams are NOT returned here — they feed Safe to Spend separately.{ id, userId, merchantId: null, predictedAmount, predictedDate, confidence, predictionConfidence, recurrencePattern, merchant: { name }, accountName, accountType }. Response envelope: { meta: { total, perPage, currentPage, lastPage }, data: […] }. Same shape returned by the dashboard's upcomingBills[] field so mobile uses one component for both.confidenceBand (no client thresholds — the old 0.8-vs-0.85 pill drift is dead), account label, amount. Swipe-to-mark-paid is deferred to MVP+ and will require a new is_paid-style column on recurring_streams or a separate per-occurrence table.recurring_streams table feeds two other flowsconf ≥ 0.3, category-gated, depository-scoped — v5.2, occurrence-expanded) and band-gated income (depository-sourced only) on the daily ledger — see that flow.query_bills_predictions tool reads streams directly via the read-only connection.KNOWN_PERIODS with tolerance bands.weekly=7 (±2), biweekly=14 (±3), semi_monthly=15 (±3), monthly=30 (±5), quarterly=91 (±10), annual=365 (±20). First match wins (narrowest tolerance). Tighten bands only with DB evidence — false positives are cheap, missed subscriptions are not.
MIN_CONFIDENCE = 0.3 (stream must clear this to persist).confidenceBand (high ≥ 0.85, medium ≥ 0.5).GeneratePredictionsJob after every sync, which refreshes the same streams when the queue worker is running. Either path keeps streams current.
recurring_streams(userId, normalizedMerchantName, frequency). Key fields: streamType (bill/income/subscription), detectedPeriodDays, anchorDay, averageAmount, lastAmount, predictedNextAmount, predictedNextDate, lastObservedDate, confidenceScore, occurrenceCount, isActive, bankAccountId.
is_paid-style column on recurring_streams or a separate per-occurrence table; old bills_predictions.is_paid feature was dropped with the table)L(d) = spendableBalance + Σinflows(≤d) − Σoutflows(≤d) projected day-by-day from today through max(month end, week end, runway payday−1), user tz. v5.6 (2026-10-04, evals finding #3): the runway payday is the next payday, except ON payday, when it is the FOLLOWING one (runwayPayday(); owner decision 5f — “this pay cycle”: hold back what is due before the next paycheck, what is due after it is that paycheck’s job). Before v5.6, a payday that was also month end (30 Sep 2026) made every horizon index 0: the pace was “cash + today’s paycheck ÷ 1 day” and This Week multiplied it by 5 unseen days — a weekly figure larger than all cash plus all income. The horizon now also always includes the week end, so no window is priced past the days the pace was computed over. Tab semantics (v5.5 — the daily-pace model): every tab is the sustainable daily pace × the days in its window. The pace x = min over d of L(d)/daysUntil(d) through the horizon [today, max(runway payday−1, month end, week end)] — spending x/day clears EVERY projected dip, not just the floor day (x·(k+1) ≤ L(k) for every day k), so multiplying it out by the window length can NEVER overdraft and there is NO floor cap. The pre-v5.5 global-floor cap made This Month collapse onto Until Payday (identical numbers) and froze the breakdown so the month's money in/out were invisible — removed. Today = dailyBudget = x (one day). This Week = x × daysInWeekWindow. Until Payday (default; when no payday is confidently detected — resolved.isFallback — the default tab auto-falls back to Today until the user picks one, mirroring Rocket Money's confidence gate) = x × daysUntilPayday (the paycheck horizon — no longer the floor lump). This Month = x × daysInMonthWindow, which REACHES across the upcoming paycheck(s): their deposits are already in L(d) on their landing days, so This Month legitimately exceeds cash on hand by exactly the post-payday income, and the Home breakdown decomposes it (cash − money out + money in, reconciling to the penny). In a SHORTFALL (x < 0 ⇔ floor < 0) every window collapses to the floor (the true deficit) — a negative pace × more days would overstate the hole. The response carries floor {date, value, crossesPayday, bindingBill {label, amount, date} | null} — the lowest-balance dip, now surfaced as floorValue; it no longer caps any tab but still powers Home's contextual footnote and the Coach narration identically ("your balance hits its lowest point around {date} after {bill}"). v5.7 (2026-10-04, round-2 evals): dropShadowIncome() drops an unconfirmed income stream that re-detects a user-confirmed paycheck slice (same account, amount within 5%) — it had added ~$1,912 twice to This Month. Remaining seam: obligations just past BOTH the runway and month end are still invisible (the “next payday + one full cycle” horizon was considered on 2026-10-04 and rejected by the owner as over-conservative). Negative amounts are NEVER clamped — shortfalls ship as first-class shortfall fields. Money Out is a MECE partition (per-window moneyOut): bills/utils/subs (category-gated streams, conf ≥ 0.3) · loans (loan-payment streams; loan account balances stay excluded) · planned savings (own labeled line — still reduces STS) · CC due-this-cycle (per-card ladder: max(0, statement − payments since issue) on the DUE DATE — never the total revolving balance; fallback: detected cc_payment stream "estimated" → minimum payment → explicit ccDueUnknown) · essentials (PFC necessities whitelist, checking-funded only, robust per-weekday mean, cold-start blend min(1, days/60), omitted <14d) · habits (v5.4) (everyday discretionary OUTSIDE the essentials whitelist — dining, shopping, entertainment; non-recurring via recurring_stream_id IS NULL, checking/debit-funded only (like essentials), robust per-weekday mean p90-capped, omitted <60d — typical_discretionary_service.ts, methodology metadata in typicalDiscretionary). v5.4 promoted the old context-only “if your habits repeat” projection INTO the formula: the promotion forced two scope fixes — checking-funded only (a CC swipe never leaves checking before payday — the ccDue ladder already carries the statement; and set-aside depository subtypes like savings/prepaid never draw on the runway — counting either = double charge; reverses the v4.7 CC broadening that was only correct as context) and essentials-whitelist exclusion (the old sample included groceries/gas, double-counting the essentials term — this bug made the old projection line overly pessimistic). All recurring terms are occurrence-expanded (3-paycheck months count 3 paychecks; weekly bills count every week), stale streams (>0.75× period overdue) are suppressed to possibleBills, and projected occurrences matched to a posted/pending transaction within ±4 days are dropped (availableBalance already nets them). Money In: income streams by unified band — high (user-confirmed or ≥0.85) at full amount, medium at p25 "estimated", low excluded. Multi-account paycheck (v5.3): a direct-deposit split is N confirmed slices (one account-scoped stream each) of ONE logical paycheck — every slice lands on the ledger so per-account math stays right, and the payday envelope's predictedAmount is the SUM over slices. Confirmed semi_monthly slices get their occurrence dates from the user's two-anchor pattern via expandSemimonthlyPaydays() (15th + EOM or a day pair, weekend dates rolled back to Friday) — expandOccurrences' single anchorDay steps one month per occurrence and would halve a semimonthly paycheck. (The typed-in Settings paycheck amount was removed 2026-06-12 — confirmed real deposits ARE the declaration, grounded in observed transactions and self-updating as pay changes; its substitution + skip-other-slices machinery went with it.) Stream sourcing (v5.2 — depository scoping): outflow, income, and cc_payment streams enter the money math 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 bug this fixes). Credit-card-billed subscriptions/bills are display-only (Recurring Expenses credit view) — checking never pays them directly; they live inside the card’s statement, which the ccDue ladder already subtracts, so counting both was a DOUBLE CHARGE (the field bug: a claude.ai subscription billed to the card was also subtracted from checking). Credit-side "payment received" mirrors that the classifier read as income are never income — that credit is money LEAVING checking, not arriving. Excluded streams land in partitionStreams()’s excludedNonDepository bucket and deliberately do NOT resurface as possibleBills. The gate is ONE shared rule — RecurrenceDetectionService.isMoneyMathEligible() — applied by every money-math consumer: the STS ledger (partitionStreams), payday resolution (resolvePayday primary-stream ranking — a card mirror, often larger than the paycheck, must never BECOME the payday), Coach tools 4/5 (bills + payday predictions), and /predictions/payday upcoming[]. Per-card extras: ccObligations[] (due, basis, APR, total) + ccNextCycleWarning ("$X building toward next statement", escalates when > 50% of expected paycheck). Shared service safe_to_spend_service.ts (pure cores: deriveLedgerTabs() + partitionStreams(), both exported and unit-tested DB-free) — Home and AI Coach calculate_safe_to_spend both delegate, so numbers match byte-for-byte.| Category | Captured? | How |
|---|---|---|
| Past debit / checking spending (cleared) | ✓ | spendableBalance reads availableBalance from Plaid checking accounts only (v4.9 — savings excluded as deliberate behavioral choice; surfaced separately as setAsideAccounts) — cleared txns already reduced the balance |
| Past debit / checking spending (pending) | ✓ | Plaid availableBalance subtracts pending debits automatically. Pending charges typically appear within minutes-to-hours of swipe; posted charges lag 1-3 days |
| Past credit-card spending | ✓ | per-card moneyOut.ccDue on the DUE DATE via the statement ladder (statement net of payments → stream estimate → minimum → ccDueUnknown); creditCardDebt = total revolving balance, DISPLAY ONLY (v5) |
| Future recurring bills (rent, Netflix, gym) | ✓ | upcomingBills from recurring_streams with conf ≥ 0.3 |
| Future recurring income (paycheck) | ✓ | expectedIncomeTotal from income streams by unified band (high full, medium p25 "estimated", low excluded); v5.3 sums all confirmed multi-account slices |
| Future non-recurring discretionary habits (dinner, Target, impulse) — checking/debit only | ✓ | moneyOut.habits (v5.4 — SUBTRACTED like essentials; was context-only v4.6–v5.2). Per-weekday robust mean over non-recurring, non-essential checking/debit spend, spread across every ledger day; per-window methodology metadata in typicalDiscretionary (null <60d → term omitted). CC swipes are deliberately OUT (the ccDue ladder carries them). UI: “Spending habits” Money Out row tagged “estimated”. See the full math spec → |
GET /predictions/safe-to-spend?timezone=America/New_YorkIntl.DateTimeFormat().resolvedOptions().timeZone. Segmented control shows Until Payday / Today / This Week / This Month — all 4 tabs always visible, identical styling. Default: Until Payday.resolvePayday() helperquery_payday_predictions + calculate_safe_to_spend tools use, so Home and Coach quote the same date. Two cases (STS v5, 2026-06-11): (1) user_setting — user has told us the cadence in Settings (Profile tab → Settings). Monthly uses payday_day_of_month; semi-monthly uses the free day pair payday_day_1/payday_day_2 when both set (e.g. 5th + 20th, clamped per month), else the legacy two-pattern overload (payday_day_of_month: 1 → 1st + 15th, 15 → 15th + last-day-of-month). v5.3: both semimonthly forms route through expandSemimonthlyPaydays() with the business-day roll-back: a pay date on Saturday/Sunday pays the preceding Friday (US payroll ACH convention; field case — May 31 2026 is a Sunday, real deposits landed Fri May 29; holidays deliberately not modeled); biweekly/weekly derive the anchor from the primary income stream when user-confirmed or band ≥ medium. (2) inferred — no setting, or the setting couldn't compute. Next payday = the EARLIEST upcoming pay event across ALL eligible income streams (user-confirmed, or band ≥ medium; eligibility is category-gated to category IN (NULL, 'income') so recurring transfer-ins/refunds can never become "the payday"); else the today+14 placeholder with isFallback: true — consumers must never render the fallback as a real date (Until Payday becomes a "Set your payday" CTA). Amounts by unified band (confidence_bands.ts: low <0.5, medium 0.5–0.85, high ≥0.85-or-user-confirmed): v5.3 multi-account paycheck: when confirmed slices exist, predictedAmount = the SUM of every user-confirmed money-math-eligible income stream's resolved amount (resolveConfirmedPaycheckTotal() — the slices are one paycheck split by direct deposit, landing together; basis predicted); else high band → full predicted amount; medium band → p25 of the stream's observed deposits, labeled estimated_p25 (a $700–$1,300 gig earner is credited ~$775, not $0); low → null. User confirmation upgrades date AND amount (the old conf-0.3 pin that excluded a confirmed paycheck's amount is retired). Returns ResolvedPayday envelope: { nextPayday, predictedAmount, frequency, source, confidence, band, isFallback, amountBasis, daysUntilPayday, isPaydayToday, anchorDate } — band is a server-computed label; clients/Coach must never re-derive it from the raw score. Postgres date columns are normalized via toUserLocalDate() to avoid UTC-midnight-to-Eastern-evening shifts that previously slid May 2 bills into the May 1 window. How streams get confirmed (v5.3 multi-select): GET /paycheck-confirmations/candidates lists deposits ≥$500 in the last 60 days on depository accounts (each candidate carries confirmedStreamId for pre-selection; response includes confirmedStreams[] + the legacy currentlyConfirmed); POST /paycheck-confirmations {transactionIds[]} (legacy transactionId accepted) validates every pick is an inflow on a money-math-eligible account, groups picks per (bankAccountId, normalizedMerchantName) slice — several pay dates into one account become OBSERVATIONS of that slice (real occurrenceCount/average) — upserts each slice by the account-scoped identity key with user_confirmed=true, writes back transactions.recurring_stream_id, sets predictedNextDate from the semimonthly pattern (else +period), and clears user_confirmed from any income stream OUTSIDE the new set — the confirmed set IS the paycheck.bank_accounts WHERE is_active = true. Checking → spendableBalance (sum of available_balance, fallback current_balance — available already nets pending debits, load-bearing for occurrence dedupe). Credit → creditCardDebt (total revolving — v5: DISPLAY ONLY, never subtracted) + credit_card_liabilities rows for the per-card due-this-cycle ladder. Loan accounts surfaced as loansExcluded[] (balances never in the formula; loan PAYMENT streams are the loans bucket). Decimals parsed with Number().recurring_streams WHERE user_id = ? AND is_active = true, partitioned by the exported pure partitionStreams() (v5.2, DB-free unit-testable) over the v5 category column (legacy NULL rows fall back to streamType semantics). The v5.2 depository gate runs first: streams sourced from a non-depository account (accountType !== 'depository'; NULL linkage passes — the legacy guarantee, an unlinked paycheck must never be dropped) exit to excludedNonDepository and enter NO money-math bucket and NOT possibleBills — their display home is the Recurring Expenses credit-card view (a card-billed subscription already lives inside the card’s ccDue statement repayment, so also charging checking was a double charge; a card’s "payment received" credit mirror is never income). Survivors: bills/utils/subs (conf ≥ 0.3) · loans · planned_savings · cc_payment (NEVER in the bills bucket — consumed only by the CC ladder) · income (band-gated: high full, medium p25 "estimated", low excluded; transfer_in never counts). Stale streams (>0.75× period overdue, isStreamStale) demoted to possibleBills. Every kept stream is occurrence-expanded over the ledger range (expandOccurrences) — EXCEPT confirmed semi_monthly income slices when the user's setting is semimonthly, which expand via the two-anchor expandSemimonthlyPaydays() pattern instead (v5.3; the single-anchor stepper under-expands semimonthly to one occurrence per month) — and each occurrence matched against posted/pending member transactions within ±4 days is dropped. Streams joined with bank_accounts for labeling.L(d) accumulates from spendableBalance. The pure core deriveLedgerTabs(ledger, {paydayIdx, weekEndIdx, monthEndIdx}) (horizon = the max of all three, v5.6) returns the sustainable daily pace (Today / dailyBudget) and every tab as pace × its window days (no cap; This Month reaches across upcoming paychecks), plus floorValue (the dip) and shortfall indices — unit-tested in tests/unit/sts_ledger.spec.ts (the formula's first arithmetic tests). Windows: Today = now.endOf('day'); This Week = now.endOf('week') (ISO Mon-Sun); Until Payday = runwayPayday − 1 day (v5.6: on payday the following payday, resolved by a second resolvePayday call at tomorrow; a fallback following payday keeps the old one-day runway); This Month = now.endOf('month'). Each TimeframeData: { amount, daysInWindow, perDayInWindow, moneyOut {billsUtilsSubs, loans, plannedSavings, ccDue, essentials, habits, total}, bills[], income[], possibleBills[], upcomingBillsTotal, expectedIncomeTotal, windowEnd }. In a shortfall (negative rate) allocations collapse to the floor — never "negative rate × days".{ today, thisWeek, untilPayday, thisMonth, dailyBudget, daysUntilPayday, nextPayday, isPaydayToday, runwayPayday, resolved, spendableBalance, creditCardDebt, shortfall, ccObligations[], ccDueUnknown, ccNextCycleWarning, essentials, accountsIncluded, setAsideAccounts, creditCards, loansExcluded, lastSyncedAt, typicalDiscretionary }. v5 additions: shortfall {isShortfall, shortfallAmount, earliestShortfallDate, bindingConstraint {date, label}}; ccObligations[] per card {amountDue, basis: statement|stream_estimate|minimum|unknown, dueDate, minimumPayment, lastStatementBalance, purchaseApr, totalBalance, nextCycleAccruing}; ccNextCycleWarning {amount, monthLabel, escalate}; essentials {active, lookbackDays, blendFactor, perDayOfWeek}. v5.4: each window’s moneyOut carries the new habits bucket (inside total); typicalDiscretionary {perDayOfWeek, sampleSize, lookbackDays, observedDays, methodology, windows} is its methodology metadata (null = <60d history → habits term omitted). v5.1 (mobile PR): the window key is thisMonth (formerly restOfMonth — renamed backend+mobile together so the live client never broke). Each TimeframeData includes daysInWindow + perDayInWindow for breakdown math. resolved is the 2-case payday envelope: { source: 'user_setting' | 'inferred', confidence, band, isFallback, amountBasis, anchorDate, predictedAmount, frequency } (STS v5 adds the server-computed band label, the isFallback never-render-as-date flag, and amountBasis). No more paydaySource alias, thisMonth alias, isStale, or divergenceWarning — all retired in v4.4.colors.textPrimary) — the green/yellow/red health-ratio color system (accent hero color, glow gradient, the "what the colors mean" sheet + its first-run dot) is DELETED. Semantic color now lives only on row amounts as direction colors: money in = positive green, money out = danger red, context rows muted; the shortfall alert keeps generic danger styling. Until Payday shows a "$X/day to stay on pace" pacing line under the subtitle. v5.6: on payday the Until Payday subtitle reads “payday today 🎉 · safe until next payday (Thu Oct 15)” (from runwayPayday), and the “across your upcoming paychecks” multiplier line compares a window against untilPayday.daysInWindow (the real runway), not daysUntilPayday. No detectable payday (resolved.isFallback) → the Until Payday tab renders a "Set your payday" CTA (deep-link to Settings) instead of the placeholder date; the other tabs stay live. Payday band < high → an inline "We’re estimating your payday — confirm it" callout deep-links to confirm-paycheck (Settings stays the editor, the card is the prompt surface). Shortfall (shortfall.isShortfall) → red alert row "Short $X — {bindingConstraint.label} on {date}"; the hero shows the true negative (no clamping anywhere). Next-statement warning (ccNextCycleWarning.escalate) → the caution alert row "$X building toward your {month} statement" is now TAPPABLE: it expands to one inset SubRowCard per credit card showing nextCycleAccruing (null → an honest "unknown — re-link bank" row), with its own ⓘ tip. Expanded breakdown = the 3-variable layout (Cleo-style, rendered INLINE under the hero; v5.5 the variable rows are scoped to the SELECTED tab so This Month decomposes the month, and every Money Out category and the Money In row always render even at $0): Cash on hand (per-account drill) − Money out (tap reveals SIX soft inset SubRowCards, ALL always rendered even at $0 — the subtracted-categories list IS the mental model — in the order Bills/utils/subs · Credit card repayments (basis tags plus inline "amount unknown — re-link bank" cards per basis=unknown obligation) · Loan payments · Everyday essentials · Spending habits (v5.4 — the promoted habits term; its ⓘ tip names what it sits outside of: essentials, bills/subs/utils, loans, card repayments) · Planned savings; the two ESTIMATED rows (essentials, habits) show an em dash + "still learning" until their forecast has enough history (essentials <14d, habits <60d), never a fake $0.00, else an "estimated" tag — followed by an "Estimates are based on your transaction history." footnote) + Money in (per-stream drill, "estimated" tag for p25 amounts) = the highlighted "= Safe to Spend" total card (elevated surface, slightly larger amount) showing the SELECTED tab’s amount, a "$X per day until {payday}" pacing line (suppressed when the payday is the synthetic fallback), the outcome-framed derivation line on every tab ("your sustainable daily pace" on Today; "$X/day × N days" on This Week / Until Payday; "$X/day × N days, across your upcoming paychecks" on This Month when it crosses payday; ledger vocabulary never reaches the screen), and AT MOST ONE contextual footnote per tab (declutter rule), in priority order: the binding-dip note on This Month / Until Payday ("Your balance hits its lowest point around {date}, after {bindingBill} (−$X). This number keeps you above $0 through that dip" + "— expected income counts only from the day it lands" when it crosses payday), else "Limited by {bill} on {date}" (Until Payday), else the still-learning note (v5.4: covers whichever estimated terms are inactive — essentials <14d and/or habits <60d — "Doesn't include everyday essentials / your spending habits yet"). Every row carries a small ⓘ InfoTip whose copy comes from the single STS_TIPS map in sts-copy.ts (one copy source — trust explanations can’t fork): cashOnHand / moneyOut / billsUtilsSubs / ccRepayments / essentials / habits / loans / plannedSavings / moneyIn / safeTotal / setAside / ccAccruing. The old "Where do these numbers come from?" sources sheet is DELETED — its trust copy (bill detection story, CC basis ladder, trust-gated income rules) was redistributed to the per-row tips, and safeTotal keeps the "same number the AI Coach reads" guarantee. Under the total card (v5.4): the old "If your habits repeat: ~$X left" projection line and its methodology sheet are DELETED — habits are now subtracted inside Money Out, so the hero already IS the habits-repeat number (keeping the line would have read as a second, double-counted deduction). Then the muted "Set aside in savings · not counted" drill (v4.9 behavioral choice). (The old "Coming up in this window" drill was removed in v5.5 — Money In is now a first-class always-on row, making it redundant.) Single-open drill accordion throughout; Money Out’s group toggle is independent state so its sub-rows can drill while the group stays open. Components: dashboard/safe-to-spend/ (SafeToSpend container · LedgerBreakdown · MoneyOutSection · SafeTotalCard · CcAccruingAlert — ProjectionRow + MethodologySheet retired in v5.4) + the ui/SubRowCard atom.react-native-plaid-link-sdk v12.8.0) for production bank connections, including OAuth redirect for institutions like Chase. End-to-end: mobile requests a Link token, Plaid Link UI runs natively, token exchange stores an encrypted access_token, account rows seed, then SyncDailyTransactionsJob backfills the last 30 days. Webhooks keep subsequent updates asynchronous. Re-link (update mode, STS v5.1): items linked before the liabilities product was requested return ADDITIONAL_CONSENT_REQUIRED from /liabilities/get (adapter swallows it as []) — every card rides the basis ladder down to "unknown" until the user re-links. "Re-link bank" on the Accounts screen mints an update-mode Link token (POST /api/v1/bank-connections/:id/relink-token → PlaidService.createRelinkToken → plaidAdapter.createUpdateLinkToken with additional_consented_products: [liabilities]; Bouncer edit-authorized), opens native Plaid Link against the EXISTING item (no public-token exchange — the access token stays valid), then triggers POST /bank-connections/:id/sync so statement balances/due dates/APRs land immediately and the Safe to Spend card-due ladder upgrades from "unknown" to statement-grade.xcodebuild + installed via xcrun devicectl (iOS) — see LEARNINGS.md. SKIP_AUTH dev bypass doesn't block this — it still creates the connection under user ID 53.POST /api/plaid/link-token to request a Link tokenapps/backend/app/controllers/plaid_controller.ts:7-25. Empty body — never send null, AdonisJS bodyparser strict mode rejects "null" with 422 (see CLAUDE.md Gotchas). Use {}.linkTokenCreate via BankDataProvider adapterpackages/bank-data-provider — never direct in app code (CLAUDE.md). Request includes client_user_id (internal user ID), product transactions, optional product liabilities (STS v5 — institutions without liability support still link cleanly), transactions.days_requested: 180 (Plaid default 90 starves quarterly-stream + 3-paycheck-month detection), country US, language en. No redirect_uri for the native iOS SDK — it handles OAuth returns internally (a redirect URI is only needed for web-based Link; if ever reintroduced it requires the Associated Domains entitlement + apple-app-site-association at the redirect host).react-native-plaid-link-sdk presents the native institution picker. Not a WebView — this was a deliberate switch from the WebView flow because OAuth institutions (Chase primarily) require the native SDK for reliable redirect handling. User picks bank, authenticates, selects accounts.SFSafariViewController, user logs in, bank redirects to the universal link URL. iOS delivers the universal link back to our app, Plaid Link picks it up via continue(from: redirect_uri), and resumes the Link session. Plaid returns a public_token + accounts metadata to the mobile callback. Non-OAuth banks skip the redirect entirely — same outcome, one less round trip.POST /api/plaid/exchange with { publicToken, metadata }exchangeAndPersist() — itemPublicTokenExchange + row creationapps/backend/app/services/plaid_service.ts:39-86. Exchanges public_token for a permanent access_token + Plaid item_id. Creates a bank_connections row with the access token AES-256 encrypted at rest, serializeAs: null so it never leaves the backend. Institution name + logo persisted. Then per-account: bank_accounts rows created with Plaid's account_id, masked number, type/subtype, balance snapshot, isActive = true.syncAccounts() refreshes balances immediatelyaccountsGet returns current balances for all accounts under the new item. Writes availableBalance, currentBalance, limit to each bank_accounts row. Ensures Home renders a real balance on the very next render, not "—".syncCreditLiabilities() ingests credit-card cycle data (STS v5)liabilitiesGet returns per-card last_statement_balance, last_statement_issue_date, next_payment_due_date, minimum_payment_amount, last_payment_amount/date, is_overdue, and aprs[] — upserted 1:1 into credit_card_liabilities keyed by bank_account_id with synced_at. Failure-tolerant: NO_LIABILITY_ACCOUNTS / PRODUCTS_NOT_SUPPORTED return [], any other error logs and continues — liabilities never block a link or sync. Every field is nullable per institution; the STS v5 fallback ladder (statement math → detected cc_payment stream → minimum → explicit unknown) owns degradation. Also runs on manual sync (syncNow step 1b) and the RefreshBankConnectionJob.POST /api/webhooks/plaid on future events (TRANSACTIONS: INITIAL_UPDATE, DEFAULT_UPDATE, HISTORICAL_UPDATE; ITEM: ERROR, PENDING_EXPIRATION). Webhook endpoint is HMAC-verified via Plaid's verification_key_id JWKS. Unsigned hits are rejected.SyncDailyTransactionsJob for initial 30-day backfilltransactions-sync. First sync uses a 30-day window (matching the daily cron's 30-day window since v5.3) to give the app immediate historical context for categorization and recurrence detection. Same circuit-breaker + retry wrapper (5 failures → open 30s, 3 attempts with 2s/4s/8s backoff). See the Transaction Sync Flow for the category resolution priority applied to these rows.{ bankConnection: { id, institutionName }, accounts: [{ id, name, mask, type, availableBalance }] }. Mobile invalidates React Query caches for ['dashboard'], ['accounts'], ['bills'], ['safe-to-spend'] so the next render shows the new bank everywhere. Initial transaction backfill is still running async — transactions appear within ~10-30s.POST /api/webhooks/plaidHISTORICAL_UPDATE (full backfill complete) / DEFAULT_UPDATE (new transactions) / TRANSACTIONS_REMOVED (tx corrected) / ITEM_ERROR (credentials expired — user must re-auth) events fan out to ProcessWebhookEventJob. Each triggers a targeted sync for the affected item — much cheaper than the full 2 AM cron.react-native-plaid-link-sdk v12.8.0 (native module, NOT WebView). Requires a custom Expo dev build — Expo Go cannot load native modules. iOS build via xcodebuild, installed via xcrun devicectl. Android: standard expo prebuild + Gradle build.
redirect_uri = universal link URL to linkTokenCreate. iOS app must declare the matching domain in Tide.entitlements (applinks:<domain>) and the host must serve apple-app-site-association with this app's bundle + team ID. Without either, the Link session hangs at the OAuth return step.
access_token is stored AES-256 encrypted on bank_connections.access_token with serializeAs: null — never appears in any API response, never logged. All logs use redactForLogging(). client_id/secret live in backend env vars, never shipped to mobile.
BankDataProvider interface with circuit breaker (failureThreshold=5, resetTimeoutMs=30000, requestTimeoutMs=10000) and exponential backoff retry (3 attempts: 2s/4s/8s). Circuit open → fail fast; user sees "Connection paused, try again in 30s" rather than a cascade of errors.
POST /api/webhooks/plaid — HMAC-verified via Plaid JWKS (verification_key_id). Events handled: TRANSACTIONS.INITIAL_UPDATE, TRANSACTIONS.HISTORICAL_UPDATE, TRANSACTIONS.DEFAULT_UPDATE, TRANSACTIONS.TRANSACTIONS_REMOVED, ITEM.ERROR, ITEM.PENDING_EXPIRATION. Each maps to ProcessWebhookEventJob. Unsigned hits → 401.
PLAID_ENV=sandbox → PLAID_ENV=production and rotate PLAID_SECRET. Sandbox-specific heuristics (pending/posted dedup fallback in Spending Insights) stay valid in prod because real banks also sometimes omit the FK.
itemRemove exists, mobile UI not shipped)ITEM_ERROR (push + deep link back into Plaid Link update mode)spending_query.ts SQL predicates + spending_insights_math.ts pure partition/aggregation) also power the Home-tab Spending chart (GET /transactions/monthly-summary) and the AI Coach's get_spending_by_category + get_financial_insights(spending_trends) tools — see the "One spending-math core" spec block below. The legacy unfiltered analytics endpoints (/insights/spending-trends, recurring-charges, category-breakdown, anomalies, largest-expenses, /transactions/by-category) were deleted — they summed raw amounts with none of these filters and had zero consumers.timeframe='week'. Timezone auto-detected via Intl.DateTimeFormat().resolvedOptions().timeZone. Optional account filter (bankAccountIds array) selects which depository + credit accounts to include.GET /insights/spending-by-category?timeframe=week&timezone=America/New_YorkuseSpendingByCategory — cached in-memory with 60s staleTime. QueryKey includes timeframe, bankAccountIds, includePending, timezone, AND the device-local calendar date (YYYY-MM-DD) — different filters cache separately, and the date component forces a fresh window at local midnight. Full freshness model (focus refetch, sync invalidation, midnight rollover) in the spec block below.spendingByCategoryValidator → InsightsController.spendingByCategory(). The timezone param is validated as a real IANA zone (timezone_rule.ts, Luxon IANAZone.isValidZone) — junk zones are a 422 at the validator boundary, not a 500 from an Invalid DateTime (2026-06-12 hardening; same rule on /transactions/monthly-summary). Window comes from the shared resolveSpendingWindow() (spending_insights_math.ts, pure — also used by the Coach tool):day = now.startOf('day') to now.endOf('day')week = rolling 7 days: now.minus({days:6}).startOf('day') to end-of-todaymonth = now.startOf('month') to end-of-todayfetchSpendingDetailRows() in spending_query.tstransactions.user_id = user.id + transaction_date within window + is_pending = false (default) + optional bank_account_id IN (bankAccountIds). Since 2026-06-12 this predicate set lives in spending_query.ts — the ONE place that knows how to pull spending rows — consumed identically by this endpoint, the Home monthly chart, and the Coach tools (which pass connection: 'readonly').db.from('transactions') .leftJoin('categories as c', 'transactions.category_id', 'c.id') .leftJoin('categories as p', 'c.parent_code', 'p.code')c.id, c.code, c.name, c.parent_code, p.name as primaryName, p.icon, p.color, SUM(amount), COUNT(*). GROUP BY all selected columns.excludeSupersededPending() — pending deduptransaction_filters.ts. Two conditions (NOT …): (a) Plaid's pending_transaction_id points to this row's plaid_transaction_id, OR (b) heuristic: same user_id + bank_account_id + merchant_id + amount + date within 7 days AND this row is pending with a posted sibling. The heuristic covers Plaid sandbox where the FK is rarely set.excludeCreditCardSideOfCcPayment() — CC payment double-side filterLOAN_PAYMENTS_CREDIT_CARD_PAYMENT. In spending contexts we suppress the credit-card-account side so the payment only counts once. Applied unconditionally in this endpoint; applied in /transactions when a category context (categoryId, parentCategoryCode, or uncategorized) is set AND the single bankAccountId is not — the multi-account bankAccountIds[] does NOT disable it, matching the donut's account-filtered behavior (per-account views via single bankAccountId still show both sides). Decided by the pure isSpendingContext() in transaction_list_query.ts.classifyDetailRow() in spending_insights_math.ts (pure, unit-tested). Order: (1) null category_id → uncategorized; (2) categoryCode === LOAN_PAYMENTS_CREDIT_CARD_PAYMENT → ccPayment — this is the CHECKING-side debit of a credit-card bill payment (the CC-account side was already dropped by the SQL filter in step 7); it is NOT spending because the card swipes already counted in their own categories at purchase time. Other LOAN_PAYMENTS leaves (auto / student / mortgage) remain spending; (3) parent_code in NON_SPENDING_PRIMARIES = ['INCOME', 'TRANSFER_IN', 'TRANSFER_OUT'] → excluded; (4) everything else — including the CASH primary (reparented ATM withdrawals) — is "spending."aggregateSpendingByPrimary() in spending_insights_math.ts. For each spending row, accumulate into the primary bucket: sum totalAmount (NET of refunds — can go negative), sum transactionCount, append to children[]. Primary metadata (icon, color, name) comes from the p.* join columns. Money math is POSITIVE-ONLY: totalSpent = Σ max(primaryNet, 0); pctOfTotal = Math.round((primaryNet / totalSpent) × 1000) / 10 (1 decimal) for positive nets, 0 for nets ≤ 0 — so percentages always sum to ~100. A negative-net (refund-heavy) primary keeps its net totalAmount in the response (mobile renders it as a "money back" row) but is never a slice and never in the denominator. Sort by totalAmount desc → money-back rows fall to the bottom.SpendingByCategoryResponse JSON{ timeframe, windowStart, windowEnd, timezone, totalSpent, transactionCount, primaries: [{ primaryCode, name, icon, color, totalAmount, transactionCount, pctOfTotal, children: [...] }], excluded: { income, transfersIn, transfersOut, ccPayments }, uncategorized: { totalAmount, transactionCount } }. totalSpent = sum of POSITIVE primary nets (the donut-center number); excluded.ccPayments = checking-side CC bill payments. Primaries sorted by totalAmount desc (money-back rows last); children within each primary also sorted desc.CategoryDonut component (animated slices by color). Mobile maps only totalAmount > 0 primaries to slices; the component also defensively drops non-positive slice values (a negative value would draw a negative strokeDasharray arc). If every category nets ≤ 0, the donut shows the empty grey ring with $0 — never a broken ring. The center amount is constrained to the donut hole (maxWidth = size − 2×thickness − 16, adjustsFontSizeToFit, min scale 0.55) so large totals shrink instead of clipping under the arcs. Each positive primary row: icon in a colored circle, name, txn count · formatPercent(pctOfTotal), totalAmount, chevron. Tiny percentages render as <0.1% not 0%. Negative-net primaries render as "money back" rows: meta says Money back (no percentage), amount in credit styling (green, +$40.72), still tappable for drill-in. Footer: "Not counted in spending" with collapsed Transfers (tappable), Credit card payments (info icon explains the purchases-already-counted-at-swipe rationale; not tappable) + Uncategorized (tappable row + info icon; tap drills into the uncategorized transactions for the current window). Income and Transfers-in are hidden — not spending-adjacent.primary.children.length === 1, navigate direct to /(tabs)/all-transactions with params {categoryId, categoryName, startDate, endDate, timeframeLabel, categoryColor, categoryIcon, accountIds}. Otherwise push to /(tabs)/spending/[primaryCode] drill-down with timeframe + accountIds + the window. EVERY drill path (single-child shortcut, drill-down child taps, footer Transfers / Uncategorized rows) forwards accountIds — the donut's account-chip selection — and all-transactions APPLIES it to the query as bankAccountIds[], so an account-filtered slice never drills into an all-accounts list. The drill-down page treats the window params it was HANDED as authoritative when forwarding (params.startDate ?? data.windowStart) so a cache refresh can't shift the window mid-flow. Child taps additionally pass the breadcrumb fromPrimaryCode / fromTimeframe / fromAccountIds so the Back button on all-transactions returns to the exact drill-down page (not Home, not Spending index).all-transactions: category icon (colored circle), name, total, transaction count, date range like Apr 13 – Apr 19, × to clear. useInfiniteTransactions sends categoryId + startDate + endDate + bankAccountIds[] + isPending=false (drill-in default; Posted / All / Pending chips switch it) to GET /transactions, which applies the same pending dedup + CC double-side filters as the donut. The header total is meta.netAmount — a server-side SUM(amount) over the FULL filtered set (all pages, refunds net out, rounded to cents like the donut; positive = spent) — and the count is meta.total. No client-side summation over loaded pages. A Transfers drill-in header therefore shows the same $1,500.00 magnitude its rows render as −$1,500.00 (rows use money-out notation).day · today only · week · rolling last 7 days (not calendar week) · month · calendar month-to-date (1st → today).
Label shown to user: "Today" / "Last 7 Days" / "This Month" — week is honest about the rolling window.
SOFTWARE_AND_TECH (single catch-all subcategory SOFTWARE_AND_TECH_ALL), SUBSCRIPTIONS (entertainment subs), and CASH (ATM/bank-counter withdrawals; icon cash-outline, color #30B0C7). Reparented Plaid codes (original codes kept so Plaid sync still matches): ENTERTAINMENT_MUSIC_AND_AUDIO + ENTERTAINMENT_TV_AND_MOVIES → SUBSCRIPTIONS; GENERAL_SERVICES_INSURANCE → TRANSFER_OUT (insurance is investment-like, not discretionary spending); TRANSFER_OUT_WITHDRAWAL → CASH (2026-06-12 — cash leaving your hand IS spending, so withdrawals count in the donut instead of hiding in the excluded Transfers footer; genuine inter-account transfers — savings, investment, account transfer — stay under TRANSFER_OUT, excluded).
category_seeder.ts from database/data/category_taxonomy.ts, which builds the list from two committed files: plaid_pfc_taxonomy.csv (Plaid PFC v1 as Tide curates it) and plaid_pfc_taxonomy_v2.csv (Plaid's combined v2/v1 file, verbatim). PFCv2 is a superset of v1, and Plaid customers created after Dec 2025 receive v2 codes. 2026-10-03 fix: Tide only knew v1, and Plaid codes match Tide categories by EXACT code, so 26 v2 codes (INCOME_SALARY, the Zelle/Venmo *_FROM_APPS transfers, …) were saved uncategorised and Income read $0. All v2 codes v1 lacks are now seeded. The new v2 primary LOAN_DISBURSEMENTS goes under TRANSFER_IN (as v1 filed loan payouts), and the v2 catch-all OTHER_OTHER is deliberately left uncategorised (INTENTIONALLY_UNMAPPED). resolveCategoryCode(): an INCOME_* code on a credit card is a card payment (LOAN_PAYMENTS_CREDIT_CARD_PAYMENT), never income, because Plaid v2 labels a card's "AUTOMATIC PAYMENT – THANK YOU" as salary. A Plaid code with no Tide category now logs [Sync] Plaid category codes with no Tide category…, and category_taxonomy.spec.ts fails if any v2 code lacks a home. Repair for existing rows: node ace categories:backfill-plaid-codes [--apply] (only rows with no category). NAME_OVERRIDES map handles display names for reparented codes (TRANSFER_OUT_WITHDRAWAL displays as "Cash Withdrawal").
excludeSupersededPending) — hides pending rows that have a posted sibling. Primary rule: pending_transaction_id FK; heuristic fallback: user + account + merchant + amount within 7 days.
excludeCreditCardSideOfCcPayment) — suppresses the credit-card-account side of LOAN_PAYMENTS_CREDIT_CARD_PAYMENT. Applied unconditionally in the insights query (per-account semantics don't apply here since this is aggregate spending).
classifyDetailRow) — the surviving CHECKING-side debit of a CC bill payment is partitioned into excluded.ccPayments, NOT spending: the card swipes already counted in their own categories at purchase time, so counting the bill too would double-count every dollar ($500 of purchases + the $500 payment must equal $500 spent, not $1,000). Other LOAN_PAYMENTS leaves (auto/student/mortgage) remain spending — real money out, not duplicated.
parent_code in [INCOME, TRANSFER_IN, TRANSFER_OUT] land in the excluded bucket and don't count toward totalSpent. CASH is deliberately NOT in this list.
max(primaryNet, 0) — negative nets are never slices.
totalSpent (donut center) = Σ max(primaryNet, 0) — positive nets only.
pctOfTotal = positiveNet / totalSpent (1 decimal); 0 for nets ≤ 0. Percentages therefore always sum to ~100.
totalAmount in the response and render at the bottom of the category list in credit styling (green +$40.72, meta "Money back", no percentage). Mental model: "I got money back in this category this window."
totalSpent = 0; mobile shows the empty grey ring ($0 center), never a broken ring. Implemented in spending_insights_math.ts (pure; unit-tested in tests/unit/spending_insights_math.spec.ts).
GET /transactions, a DIFFERENT endpoint than the donut — so filter + aggregation semantics are shared explicitly (pure helpers in transaction_list_query.ts, unit-tested in transaction_list_query.spec.ts):
/transactions accepts bankAccountIds[] (same validator shape as insights), composed with the legacy single bankAccountId as a deduplicated UNION (composeAccountIds()). Every Spending drill path passes the donut's account selection as accountIds and the list applies it. Ownership = the endpoint's user_id scope (foreign ids yield zero rows), identical to insights.
isPending=false (the donut's includePending=false); the Posted / All / Pending chips override explicitly. The superseded-pending dedupe (excludeSupersededPending) is always on in the list, matching the donut.
/transactions response carries meta.netAmount: server-side SUM(amount) over the FULL filtered set (computed on a clone of the exact filtered query, before pagination), rounded to cents like the donut (Math.round(x*100)/100), sign convention positive = spent. Computed unconditionally so the response shape never forks. Header count = meta.total.
startDate/endDate params) is forwarded as-is down the drill chain; screens fall back to their own hook's windowStart/windowEnd only when no params were handed.
parentCategoryCode=TRANSFER_OUT; ATM withdrawals no longer appear here — they're the CASH spending category now) and Uncategorized (tap → drills into all-transactions with uncategorized=1 + the current window's startDate/endDate + an "Uncategorized" context header; backend filter: GET /transactions?uncategorized=true → category_id IS NULL). Both footer drill-ins also forward the account-chip selection as accountIds — footer totals are account-filtered, so their drill-ins must be too.
excluded.ccPayments > 0) — the checking-side CC bill payments; the ⓘ explains that every purchase already counted in its own category at swipe time, so counting the bill would double-count.
categorySource='user' and sticks across future syncs (see the Transaction Sync flow's priority chain).
focusManager is bridged to AppState in services/query-client.ts (app-wide, at client creation). RN never fires browser visibility events, so without this bridge refetchOnWindowFocus is inert; with it, returning to the foreground refetches every stale active query.
exchangeToken, re-link openRelink in use-plaid-link.ts, pull-to-refresh useSyncBankData in use-bank-connections.ts) invalidates the shared TRANSACTION_DERIVED_QUERY_KEYS list exported from query-client.ts — transactions, spending-by-category, safe-to-spend, dashboard, predictions, bank-accounts — so newly synced transactions show up here without waiting out staleTime.
windowStart/windowEnd are computed server-side and baked into the cached response, so after midnight a cached "Today" payload is yesterday's window. The query key therefore includes the device-local calendar date (useLocalCalendarDate inside use-spending-by-category.ts): a timeout armed for ms-until-next-local-midnight (+1s buffer) bumps it and re-arms; an AppState 'active' listener catches up when the app was backgrounded across midnight (timers don't fire in the background). New date → new key → immediate fresh fetch.
transaction_type='debit' rows with pending included and server-local windows; the Home chart summed ALL transactions — income netted against spending — and mobile hid the sign with Math.abs). All now compose the same two layers: spending_query.ts (SQL predicates: pending excluded by default, superseded-pending dedupe, CC double-side) + spending_insights_math.ts (pure: partition, positive-only aggregation, window resolution; unit-tested without a DB).
GET /insights/spending-by-category (this flow). The reference surface.
GET /transactions/monthly-summary?months=N&timezone=<IANA> → fetchMonthlySpendingRows() + aggregateMonthlySpending(): per-month TRUE spending (donut semantics: Σ max(primaryNet, 0), uncategorized outside the total), last N calendar months including current month-to-date, anchored + bucketed in the user's timezone, zero-filled so the x-axis never skips a month. Response rows: { month: 'YYYY-MM', totalSpent, transactionCount }. The current month's point EQUALS the donut's "This Month" center number. Mobile (SpendingChart.tsx via useMonthlySummary) renders totalSpent directly — the Math.abs hack is gone.
get_spending_by_category resolves the same day/week/month windows in the user's timezone (from profiles.timezone — the Coach has no HTTP context; same source as calculate_safe_to_spend), fetches via the shared layer on the read-only connection, and shapes the result with the pure projectSpendingForCoach() (coach_spending_projection.ts; parity pinned by coach_spending_projection.spec.ts) — so Coach answers match the Spending tab number-for-number. get_financial_insights(spending_trends) returns the same monthly series as the Home chart. See the AI Pipeline page for tool shapes.
GET /insights/spending-trends | recurring-charges | category-breakdown | anomalies | largest-expenses and GET /transactions/by-category: raw unfiltered sums, zero consumers (mobile hooks existed but no screen imported them; hooks + response types deleted too). Any future analytics endpoint MUST compose the shared layers, not re-derive spending.
categorySource='user'). Spending Insights just READS transactions.category_id; manual corrections (PATCH /transactions/:id, optional applyToMerchant) land here on the next refetch — the mutation invalidates both the ['transactions'] and ['spending-by-category'] caches.
react-native-calendars for the range picker, validator extension, date-picker bottom sheet)Cognito.initiateAuth() with CUSTOM_AUTH flowaws-amplify Auth module. Trigger: DefineAuthChallenge Lambdalogout() in dev-bypass mode to prevent auth cache wipe.Authorization: Bearer {access_token}aws-jwt-verify with JWKS rotation. Bouncer policies check user_id === authenticatedUserId.evening-reflection.tsx, reflection-history.tsx) still exist but are not registered in the tab navigator and nothing navigates to them. Backend files preserved for MVP+ reactivation.evening-reflections queue. Queries reflection_settings for enabled users matching reminder time window.transaction_reflections record. If none found, skips user.reflection_sessions record with status pending. Sends push notification: "You have X transactions to reflect on today."transaction_reflections records.reflection_sessions.status to complete and sets completed_at. All transactions tagged.user_streaks / streak_milestones tables) was removed 2026-06-11, so reactivating reflections would track completion via reflection_sessions alone.