Dosu LogoDosu Logo
Ask
Join our Discord
SurePublic
we-promise
DocumentsSure
Financial Insights and Metrics
Financial Insights and Metrics
Type
Topic
Status
Published
Created
Jul 8, 2026
Updated
Aug 26, 2026
Updated by
Dosu Bot

Financial Insights and Metrics#

The Insights system converts Sure's AI posture from reactive (user asks chat) to proactive: a nightly job analyzes each family's finances in pure Ruby, generates typed insight objects, and surfaces them in a dashboard feed and a dedicated /insights page. The feature is introduced in PR #2550.

Eight insight types are produced by independent generators:

TypeWhat it detects
savings_rate_changeMonth-over-month savings rate shift ≥ 5 pp
cash_flow_warningProjected Depository balance < $500 in next 30 days
net_worth_milestoneNet worth crossing a round-number milestone
spending_anomalyCurrent-month category pace ≥ 25% above/below 3-month baseline
subscription_auditRecurring subscriptions worth reviewing
idle_cashLarge uninvested cash balance
budget_at_risk / budget_on_trackBudget health signal
maintained_goal_depletedMaintained reserve goal fallen below its target level

All generators run via Insight::GeneratorRegistry; a failing generator is captured by DebugLogEntry and skipped so one bad signal never blocks the nightly run .

Insight Model & Lifecycle#

The Insight model (added in PR #2550) is a family-scoped AR model with the following key fields: insight_type, title, body, priority, status, dedup_key, metadata (jsonb), generated_at, period_start, period_end, and currency.

Status lifecycle:

active → read (user opened the insight)
active → dismissed (user explicitly dismissed)
active → expired (system: condition cleared on next nightly run)
expired → active (condition returned; reactivates if not dismissed)

Dismissed insights are permanent — a returning signal never overrides a user dismissal .

Deduplication: A unique DB index on (family_id, dedup_key) ensures nightly re-runs refresh existing rows rather than creating duplicates. dedup_key encodes the insight type and a time/entity token (e.g., savings_rate_change:2026-06, net_worth_milestone:100000, spending_anomaly:42:2026-06) .

Key scopes and ordering:

  • visible — active + read rows; what the feed renders
  • ordered — priority (high → medium → low), then generated_at desc

Priority can be high, medium, or low. Generators escalate to high based on magnitude (e.g., net-worth milestones are always high; a savings rate drop ≥ 10 pp is high vs. medium for 5–9 pp) .

Generators#

All generators inherit from Insight::Generator (app/models/insight/generator.rb) and declare their type with produces. They receive a family object and call shared helpers (income_statement, balance_sheet, build_insight, format_money, etc.) .

Savings Rate Change#

SavingsRateChangeGenerator compares the two most-recently completed calendar months. The current partial month is skipped intentionally (mid-month income timing makes rates unreliable). Signal fires when the delta is ≥ 5 percentage points; ≥ 10 pp escalates to high priority. Savings rate = (income − expense) / income × 100, sourced from IncomeStatement#income_totals and expense_totals .

Net Worth Milestone#

NetWorthMilestoneGenerator checks whether the family's net worth crossed one of ten fixed milestones ($10k, $25k, $50k, $100k, $250k, $500k, $1M, $2.5M, $5M, $10M) during the last 30 days. Always high priority. The dedup_key is the milestone amount itself — each milestone is celebrated only once ever, even if net worth later dips and recovers. Data source: BalanceSheet#net_worth_series .

Cash Flow Warning#

CashFlowWarningGenerator projects combined Depository account cash forward 30 days by:

  1. Starting from current aggregate balance of same-currency Depository accounts
  2. Spreading IncomeStatement#median_expense as a daily baseline, minus known recurring amounts to avoid double-counting
  3. Adding projected RecurringTransaction occurrences (excluding transfers and cross-currency entries)

If the projected balance dips below $500, the insight fires. A negative projected balance → high priority; a low-but-positive balance → medium. dedup_key resets monthly so the signal can re-fire each month .

Spending Anomaly#

SpendingAnomalyGenerator flags parent categories whose pace-projected monthly spend deviates ≥ 25% from their 3-month average baseline. Guardrails:

  • Skips the first 7 days of a month (MIN_ELAPSED_DAYS = 7; too noisy)
  • Ignores categories with a baseline < $50
  • Skips synthetic categories and subcategories
  • Surfaces at most 3 anomalies per run, ranked by deviation magnitude
  • ≥ 50% deviation → high priority

Projection formula: current_spend × (period_days / elapsed_days). Data flows from IncomeStatement#expense_totals → category_totals. dedup_key is scoped to {category_id}:{month} so anomalies reset monthly .

Maintained Goal Depleted#

MaintainedGoalDepletedGenerator raises high priority insight when a maintained reserve goal has fallen below its target level. High priority is deliberate — unlike most insight types, which are nudges, this signals that a user-defined floor is no longer held. It is the one signal a reserve can produce that a one-off goal cannot.

Only active maintained goals trigger the check; paused reserves are excluded (a paused goal is one the user shelved on purpose; behind_pace? already excludes paused goals for the same reason). The generator selects goals with kind: "maintained" and :depleted status (reserves with current_balance < target_amount).

Limits to prevent feed overload:

  • Maximum of 2 insights per run, ordered by shortfall size (largest gap first)
  • dedup_key rotates monthly (maintained_goal_depleted:{goal_id}:{YYYY-MM}) so a reserve sitting short for weeks does not re-raise the same insight every night
  • Reserves are loaded through Goal.prepared_for to inject the family-wide pooled allocations once, avoiding N+1 queries on current_balance

The insight links to the goal page with action text from insights.actions.maintained_goal_depleted ("View reserve").

Nightly Job & Upsert Semantics#

GenerateInsightsJob (app/jobs/generate_insights_job.rb, queue: scheduled) runs at 6 AM UTC via cron . Without arguments it fans out one per-family job. Each per-family run:

  1. Acquires a PostgreSQL advisory lock (pg_try_advisory_lock) — concurrent runs are skipped, not queued
  2. Runs Insight::GeneratorRegistry#generate_all inside I18n.with_locale(family.locale)
  3. Upserts insights with the following logic:
Existing row stateAction
NoneCreate new active insight; write LLM body
Metadata changedRefresh body via LLM, set active, clear read_at/dismissed_at
expired, same metadataSet active, touch generated_at; no LLM call
Unchanged (any user state)Touch generated_at only; no LLM call
  1. Expires stale insights: visible insights of succeeded generator types whose dedup_key was not re-emitted are set to expired — the condition has cleared. Generator types that crashed are excluded from this cleanup .

ActiveRecord::RecordNotUnique races (concurrent family jobs hitting the same dedup_key) are silently skipped.

LLM Body Writing#

Insight::BodyWriter (app/models/insight/body_writer.rb) narrates pre-computed facts. The LLM acts as a writer, not a reasoner — all numbers are computed in Ruby before the LLM is invoked .

  • Provider resolved via Provider::Registry.preferred_llm_provider
  • Self-hosted installs without a configured LLM key fall back to i18n template strings (config/locales/views/insights/en.yml) — behavior is identical, just template-written prose
  • Bodies are only written on create or when metadata changed materially — unchanged insights cost zero LLM calls per nightly run

UI Surface Points#

Dashboard feed (app/views/pages/dashboard/_insights_feed.html.erb): shows the top 3 insights by priority. Populated via @feed_insights = Current.family.insights.visible.ordered.limit(3) in PagesController. Does not mark insights as read .

/insights page (app/views/insights/index.html.erb): full feed with insight cards. InsightsController#index marks visible insights as read on real page loads only — a prefetch_request? guard prevents Turbo hover-prefetch from clearing "New" badges .

Insight card (app/views/insights/_insight_card.html.erb): icon, title, body, generated timestamp, "New" pill for unread active cards, and a dismiss button.

Turbo Stream dismiss (InsightsController#dismiss): PATCH action that dismisses the insight and removes its DOM element without a full page reload.

Manual refresh (InsightsController#refresh): enqueues GenerateInsightsJob for the current family on demand.

Trends report (existing, pre-PR): app/views/reports/_trends_insights.html.erb renders a month-over-month table (income, expenses, net, savings rate) alongside three summary cards (avg monthly income/expenses/savings). Savings rate is computed inline as net / income × 100 .

Helper Behavior#

InsightsHelper (app/helpers/insights_helper.rb) maps insight types to icons, sentiments, and actions. Notable conventions:

Icons: maintained_goal_depleted uses the shield-alert icon — the same icon the reserve panel on the goal page uses for a depleted reserve — to provide visual consistency (the two read as the same object seen from two places).

Sentiment: maintained_goal_depleted takes warning sentiment, not negative. Red is reserved for money actually going the wrong side of zero; a reserve below its floor is short, not overdrawn.

Key Files#

FilePurpose
app/models/insight.rbModel: types, status/priority enums, scopes, mark_read!, dismiss!
app/models/insight/generator.rbBase generator class; shared helpers
app/models/insight/generator_registry.rbRuns all generators; captures failures; returns Result struct
app/models/insight/body_writer.rbLLM / i18n fallback prose writer
app/models/insight/generators/savings_rate_change_generator.rbSavings rate delta signal (≥5 pp threshold)
app/models/insight/generators/net_worth_milestone_generator.rbMilestone crossing detector
app/models/insight/generators/cash_flow_warning_generator.rb30-day cash projection
app/models/insight/generators/spending_anomaly_generator.rbCategory pace anomaly (≥25% deviation)
app/models/insight/generators/budget_insight_generator.rbBudget health signal
app/models/insight/generators/subscription_audit_generator.rbSubscription review prompt
app/models/insight/generators/idle_cash_generator.rbUninvested cash alert
app/models/insight/generators/maintained_goal_depleted_generator.rbMaintained reserve goal depletion alert
app/jobs/generate_insights_job.rbCron fan-out + per-family upsert job
app/controllers/insights_controller.rbindex, dismiss, refresh actions
app/views/reports/_trends_insights.html.erbMonth-over-month trends table (existing)
config/locales/views/insights/en.ymli18n strings for titles and template bodies
db/migrate/20260701120000_create_insights.rbSchema: insights table

All files in this table are introduced in PR #2550 except _trends_insights.html.erb .

Documents
Account Authorization and Permissions
Account Balance Calculation
Account Creation
Account Lifecycle Management
Account Provider Architecture
Account Reporting Controls
Account Statement Management
Account Statement Reconciliation
Account Type Architecture
AI Bank Statement Extraction
AI Chat Interface
API Authentication and Authorization
Authentication and Session Management
Balance History System
Banking Data Encryption
Banking Provider Integration
Brandfetch Logo Integration
Broker Activity Import
Budget Management
Category Management
Cryptocurrency Account Management
CSV Import and Column Mapping
Currency Management
Dashboard Filtering and Drilldowns
Depository Yield Modeling
Dev Container Setup
Dividend and DRIP Modeling
Docker Self-Hosting
Enable Banking Consent Management
Enable Banking Error Handling
Enable Banking OAuth and PSD2 Authentication
Entry Rendering
Family Data Export
Family Settings Management
Financial Insights and Metrics
Financial Reporting
FIRE Planning and Retirement Calculations
Goals and Savings Tracking
Internationalization and Localization
Investment Account Data Pipeline
Investment Account Flow Semantics
Investment Account Reconciliation
Investment Activity Labels
Investment Holdings Management
Investment Tax Treatment Classification
Investment Trade Conversion
Investment Trade Entry
Invitation Lifecycle and State Management
Ledger Entry Accounting Model
LLM Provider Configuration
LLM Request Timeout and Watchdog System
LLM Tool Calling
Manual Account Entry and Import
Manual Valuation
MCP Tool Access
Merchant Data Enhancement
Merchant Data Model
Multi-Currency Exchange Rates
NDJSON Import System
Net Worth Balance Sheet
OIDC Provider Configuration
Pending Transaction Reconciliation
Plaid Integration
Portfolio Cache and Price Resolution
Provider Import Adapter
Rails Development Environment Configuration
Rails PWA Integration
Recurring Transactions and Cash Flow Projection
REST API Architecture
Securities Lookup
Security Exchange Identification
Security Price Import Pipeline
SimpleFIN Holdings Import
SimpleFIN Integration
SimpleFIN Liability Balance Normalization
SnapTrade Integration
Split Transactions
SSO Audit Logging
SSO Authentication Flow
SSO Provider Management
Timezone-Aware Financial Data Handling
Transaction Categorization
Transaction Deduplication
Transaction Exclusion
Transaction Filtering and Search
Transaction Management
Transaction Name Resolution
Transaction Rule Engine
Transaction Sync Windows and Lookback
Transfer Management
Transfer Matching and Pairing
Turbo Frame Navigation
Yahoo Finance Integration
How split transaction child exclusion was implemented
Is it possible to edit the date of a synced transaction (e.g., one synced by LunchFlow)?
Provider Architecture