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:
| Type | What it detects |
|---|---|
savings_rate_change | Month-over-month savings rate shift ≥ 5 pp |
cash_flow_warning | Projected Depository balance < $500 in next 30 days |
net_worth_milestone | Net worth crossing a round-number milestone |
spending_anomaly | Current-month category pace ≥ 25% above/below 3-month baseline |
subscription_audit | Recurring subscriptions worth reviewing |
idle_cash | Large uninvested cash balance |
budget_at_risk / budget_on_track | Budget health signal |
maintained_goal_depleted | Maintained 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+readrows; what the feed rendersordered— priority (high → medium → low), thengenerated_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:
- Starting from current aggregate balance of same-currency Depository accounts
- Spreading
IncomeStatement#median_expenseas a daily baseline, minus known recurring amounts to avoid double-counting - Adding projected
RecurringTransactionoccurrences (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_keyrotates 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_forto inject the family-wide pooled allocations once, avoiding N+1 queries oncurrent_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:
- Acquires a PostgreSQL advisory lock (
pg_try_advisory_lock) — concurrent runs are skipped, not queued - Runs
Insight::GeneratorRegistry#generate_allinsideI18n.with_locale(family.locale) - Upserts insights with the following logic:
| Existing row state | Action |
|---|---|
| None | Create new active insight; write LLM body |
| Metadata changed | Refresh body via LLM, set active, clear read_at/dismissed_at |
expired, same metadata | Set active, touch generated_at; no LLM call |
| Unchanged (any user state) | Touch generated_at only; no LLM call |
- Expires stale insights: visible insights of succeeded generator types whose
dedup_keywas not re-emitted are set toexpired— 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
metadatachanged 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#
| File | Purpose |
|---|---|
app/models/insight.rb | Model: types, status/priority enums, scopes, mark_read!, dismiss! |
app/models/insight/generator.rb | Base generator class; shared helpers |
app/models/insight/generator_registry.rb | Runs all generators; captures failures; returns Result struct |
app/models/insight/body_writer.rb | LLM / i18n fallback prose writer |
app/models/insight/generators/savings_rate_change_generator.rb | Savings rate delta signal (≥5 pp threshold) |
app/models/insight/generators/net_worth_milestone_generator.rb | Milestone crossing detector |
app/models/insight/generators/cash_flow_warning_generator.rb | 30-day cash projection |
app/models/insight/generators/spending_anomaly_generator.rb | Category pace anomaly (≥25% deviation) |
app/models/insight/generators/budget_insight_generator.rb | Budget health signal |
app/models/insight/generators/subscription_audit_generator.rb | Subscription review prompt |
app/models/insight/generators/idle_cash_generator.rb | Uninvested cash alert |
app/models/insight/generators/maintained_goal_depleted_generator.rb | Maintained reserve goal depletion alert |
app/jobs/generate_insights_job.rb | Cron fan-out + per-family upsert job |
app/controllers/insights_controller.rb | index, dismiss, refresh actions |
app/views/reports/_trends_insights.html.erb | Month-over-month trends table (existing) |
config/locales/views/insights/en.yml | i18n strings for titles and template bodies |
db/migrate/20260701120000_create_insights.rb | Schema: insights table |
All files in this table are introduced in PR #2550 except _trends_insights.html.erb .