Add proactive financial intelligence feed

Shifts the AI assistant from reactive (users ask questions) to proactive
(the system surfaces personalized insights automatically). A nightly job
analyzes every family's financial data across 7 insight types, writes
natural-language explanations via Claude, and surfaces them in a feed on
the dashboard and a standalone /insights page.

Feature is behind a flag: off by default, enable with INSIGHTS_ENABLED=1
(or via Setting.insights_enabled in the admin UI).

Insight types:
- spending_anomaly: category spend >25% above/below 3-month rolling average
- cash_flow_warning: projected cash balance drops below $500 in 30 days
  (uses RecurringTransaction + statistical daily baseline)
- net_worth_milestone: crossed a round-number milestone or all-time high
- subscription_audit: recurring transaction overdue by 45+ days
- savings_rate_change: savings rate changed >5 percentage points vs last month
- idle_cash: $5k+ sitting in depository account with no activity in 60 days
- budget_at_risk / budget_on_track: spending pace vs monthly budget

Architecture:
- Insight model with dedup_key unique index (upsert, not re-create daily)
- Insight::Generator base class + Insight::GeneratorRegistry orchestrator
- LLM used as a writer only — financial math runs in pure Ruby
- GenerateInsightsJob runs at 6 AM UTC daily via sidekiq-cron
- InsightsController with read/dismiss Turbo Stream actions
- Dashboard section gated by Current.user.insights_enabled?

https://claude.ai/code/session_014vY9xohpm3abSAxVxRF27a
This commit is contained in:
Claude
2026-04-12 12:12:49 +00:00
parent 16a0fa08f8
commit 8ae06e37e4
27 changed files with 1173 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
class InsightsController < ApplicationController
include FeatureGuardable
guard_feature unless: -> { Current.user.insights_enabled? }
before_action :set_insight, only: %i[read dismiss]
def index
@insights = Current.family.insights
.visible
.ordered
end
def read
@insight.mark_read!
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace(@insight, partial: "insights/insight_card", locals: { insight: @insight, compact: false }) }
format.html { redirect_to insights_path }
end
end
def dismiss
@insight.dismiss!
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(@insight) }
format.html { redirect_to insights_path }
end
end
def refresh
GenerateInsightsJob.perform_later(family_id: Current.family.id)
redirect_to insights_path, notice: t("insights.refresh_queued")
end
private
def set_insight
@insight = Current.family.insights.find(params[:id])
end
end

View File

@@ -84,7 +84,17 @@ class PagesController < ApplicationController
end
def build_dashboard_sections
insights = Current.user.insights_enabled? ? Current.family.insights.for_dashboard.to_a : []
all_sections = [
{
key: "insights_feed",
title: "pages.dashboard.insights_feed.title",
partial: "pages/dashboard/insights_feed",
locals: { insights: insights },
visible: Current.user.insights_enabled? && insights.any?,
collapsible: true
},
{
key: "cashflow_sankey",
title: "pages.dashboard.cashflow_sankey.title",

View File

@@ -0,0 +1,28 @@
module InsightsHelper
INSIGHT_ICONS = {
"spending_anomaly" => "trending-up",
"cash_flow_warning" => "alert-triangle",
"net_worth_milestone" => "trophy",
"subscription_audit" => "credit-card",
"savings_rate_change" => "piggy-bank",
"idle_cash" => "clock",
"budget_on_track" => "check-circle",
"budget_at_risk" => "alert-circle"
}.freeze
def insight_icon(insight_type)
INSIGHT_ICONS.fetch(insight_type.to_s, "zap")
end
def insight_icon_color_class(priority)
case priority.to_s
when "high" then "text-destructive"
when "medium" then "text-warning"
else "text-secondary"
end
end
def insight_priority_label(priority)
I18n.t("insights.priority.#{priority}", default: priority.to_s.humanize)
end
end

View File

@@ -0,0 +1,62 @@
class GenerateInsightsJob < ApplicationJob
queue_as :scheduled
# When called from cron (no family_id), iterates all families with insights enabled.
# Can also be triggered on-demand for a single family (e.g., from the UI "Refresh" button).
def perform(family_id: nil)
if family_id
family = Family.find_by(id: family_id)
generate_for_family(family) if family
else
Family.find_each do |family|
generate_for_family(family)
end
end
end
private
def generate_for_family(family)
generated = Insight::GeneratorRegistry.generate_for(family)
upsert_insights(family, generated)
Rails.logger.info("[GenerateInsightsJob] Upserted #{generated.size} insights for family #{family.id}")
rescue => e
Rails.logger.error("[GenerateInsightsJob] Failed for family #{family.id}: #{e.message}")
end
def upsert_insights(family, generated_insights)
generated_insights.each do |gi|
existing = family.insights.find_by(dedup_key: gi.dedup_key)
if existing
# Reactivate only if the underlying numbers changed materially
numbers_changed = existing.metadata != gi.metadata
new_status = (numbers_changed && existing.dismissed?) ? "active" : existing.status
existing.update!(
title: gi.title,
body: gi.body,
metadata: gi.metadata,
priority: gi.priority,
status: new_status,
generated_at: Time.current,
period_start: gi.period_start,
period_end: gi.period_end
)
else
family.insights.create!(
insight_type: gi.insight_type,
priority: gi.priority,
status: "active",
title: gi.title,
body: gi.body,
metadata: gi.metadata,
currency: gi.currency || family.currency,
period_start: gi.period_start,
period_end: gi.period_end,
dedup_key: gi.dedup_key,
generated_at: Time.current
)
end
end
end
end

View File

@@ -44,6 +44,7 @@ class Family < ApplicationRecord
has_many :llm_usages, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
has_many :insights, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }

41
app/models/insight.rb Normal file
View File

@@ -0,0 +1,41 @@
class Insight < ApplicationRecord
belongs_to :family
INSIGHT_TYPES = %w[
spending_anomaly
cash_flow_warning
net_worth_milestone
subscription_audit
savings_rate_change
idle_cash
budget_on_track
budget_at_risk
].freeze
PRIORITIES = %w[high medium low].freeze
STATUSES = %w[active read dismissed].freeze
enum :insight_type, INSIGHT_TYPES.index_by(&:itself), validate: true
enum :priority, PRIORITIES.index_by(&:itself), validate: true
enum :status, STATUSES.index_by(&:itself), validate: true
validates :title, :body, :dedup_key, presence: true
validates :insight_type, :priority, :status, presence: true
scope :visible, -> { where(status: %w[active read]) }
scope :for_dashboard, -> { visible.ordered.limit(3) }
scope :recent, -> { where(generated_at: 30.days.ago..) }
scope :ordered, -> {
order(
Arel.sql("CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END ASC, generated_at DESC")
)
}
def mark_read!
update!(status: :read, read_at: Time.current) if active?
end
def dismiss!
update!(status: :dismissed, dismissed_at: Time.current)
end
end

View File

@@ -0,0 +1,64 @@
# Base class for all insight generators.
#
# Subclasses must implement #generate, returning an Array<GeneratedInsight>.
# Financial reasoning is done in pure Ruby using existing analytics infrastructure.
# The LLM is invoked only to write the human-readable body text.
class Insight::Generator
GeneratedInsight = Data.define(
:insight_type,
:priority,
:title,
:body,
:metadata,
:currency,
:period_start,
:period_end,
:dedup_key
)
attr_reader :family
def initialize(family)
@family = family
end
def generate
raise NotImplementedError, "#{self.class.name} must implement #generate"
end
private
def llm
@llm ||= Provider::Registry.get_provider(:openai)
end
# Generates a 1-2 sentence natural-language explanation using the LLM.
# Falls back to a bare template string if no LLM is configured.
def generate_body(prompt)
return prompt unless llm
response = llm.chat_response(
prompt,
model: Provider::Openai::DEFAULT_MODEL,
instructions: system_instructions
)
response.messages.first&.output_text&.strip.presence || prompt
rescue => e
Rails.logger.warn("[Insight::Generator] LLM body generation failed: #{e.message}")
prompt
end
def system_instructions
sym = Money::Currency.new(family.currency).symbol
<<~PROMPT
You are a concise financial insights writer for a personal finance app.
Write exactly 1-2 sentences in plain, conversational English.
Be specific with numbers. Use #{sym} for currency amounts.
Do not use jargon, emoji, or give investment advice.
PROMPT
end
def currency_symbol
Money::Currency.new(family.currency).symbol
end
end

View File

@@ -0,0 +1,22 @@
# Orchestrates all insight generators for a family.
# Catches errors per-generator so one failure doesn't block others.
class Insight::GeneratorRegistry
ALL_GENERATORS = [
Insight::Generators::SpendingAnomalyGenerator,
Insight::Generators::CashFlowWarningGenerator,
Insight::Generators::NetWorthMilestoneGenerator,
Insight::Generators::SubscriptionAuditGenerator,
Insight::Generators::SavingsRateChangeGenerator,
Insight::Generators::IdleCashGenerator,
Insight::Generators::BudgetInsightGenerator
].freeze
def self.generate_for(family)
ALL_GENERATORS.flat_map do |klass|
klass.new(family).generate
rescue => e
Rails.logger.error("[Insight::GeneratorRegistry] #{klass.name} failed for family #{family.id}: #{e.message}")
[]
end
end
end

View File

@@ -0,0 +1,69 @@
# Compares actual spending pace to the monthly budget and surfaces an insight
# when spending is significantly ahead of or behind the expected pace.
class Insight::Generators::BudgetInsightGenerator < Insight::Generator
OVER_PACE_THRESHOLD = 0.15 # 15% ahead of pace → at_risk
UNDER_PACE_THRESHOLD = -0.10 # 10% under pace → no insight (healthy)
def generate
current_period = Period.current_month_for(family)
budget = family.budgets.find_by(start_date: current_period.start_date)
return [] if budget.nil?
return [] unless budget.initialized?
budgeted = budget.budgeted_spending.to_f
actual = budget.actual_spending.to_f
return [] if budgeted.zero?
elapsed = [ (Date.current - current_period.start_date).to_i + 1, 1 ].max
total_days = [ (current_period.end_date - current_period.start_date).to_i + 1, 1 ].max
pace = elapsed.to_f / total_days
paced_expected = budgeted * pace
return [] if paced_expected.zero?
overage_ratio = (actual - paced_expected) / paced_expected
return [] if overage_ratio < OVER_PACE_THRESHOLD && overage_ratio > UNDER_PACE_THRESHOLD
at_risk = overage_ratio >= OVER_PACE_THRESHOLD
insight_type = at_risk ? "budget_at_risk" : "budget_on_track"
pct = (overage_ratio * 100).round(0)
metadata = {
"actual_spending" => actual.round(2),
"budgeted_spending" => budgeted.round(2),
"paced_expected" => paced_expected.round(2),
"overage_percent" => pct,
"days_elapsed" => elapsed,
"days_total" => total_days
}
body = if at_risk
generate_body(
"With #{elapsed} of #{total_days} days elapsed, you've spent #{currency_symbol}#{actual.round(2)} " \
"against a #{currency_symbol}#{budgeted.round(2)} budget — #{pct.abs}% ahead of the expected pace."
)
else
remaining = total_days - elapsed
generate_body(
"Your spending of #{currency_symbol}#{actual.round(2)} is on track with your " \
"#{currency_symbol}#{budgeted.round(2)} budget, with #{remaining} days left in the month."
)
end
[
GeneratedInsight.new(
insight_type: insight_type,
priority: at_risk ? "high" : "low",
title: I18n.t("insights.#{insight_type}.title"),
body: body,
metadata: metadata,
currency: family.currency,
period_start: current_period.start_date,
period_end: current_period.end_date,
dedup_key: "#{insight_type}:#{current_period.start_date.strftime("%Y-%m")}"
)
]
end
end

View File

@@ -0,0 +1,101 @@
# Projects a family's cash balance 30 days forward using two layers:
# 1. Deterministic: RecurringTransaction records (known scheduled outflows)
# 2. Statistical: daily baseline from median monthly expense divided by 30
# Surfaces an insight if the projected balance falls below WARNING_THRESHOLD.
class Insight::Generators::CashFlowWarningGenerator < Insight::Generator
WARNING_THRESHOLD = 500
PROJECTION_DAYS = 30
def generate
cash_accounts = family.accounts.visible.assets.where(accountable_type: "Depository")
return [] if cash_accounts.empty?
current_cash = cash_accounts.sum(:balance).to_f
projected, events = project_balance(current_cash)
low_date, low_balance = projected.min_by { |_, bal| bal }
return [] if low_balance.nil? || low_balance > WARNING_THRESHOLD
upcoming_outflows = events.select { |e| e[:type] == :outflow }.sum { |e| e[:amount] }
metadata = {
"projected_low_date" => low_date.iso8601,
"projected_low_balance" => low_balance.round(2),
"current_balance" => current_cash.round(2),
"upcoming_outflows" => upcoming_outflows.round(2),
"account_count" => cash_accounts.count
}
body = generate_body(
"Based on your recurring bills and spending patterns, your cash balance may drop to " \
"#{currency_symbol}#{low_balance.round(2)} around #{low_date.strftime("%B %-d")}. " \
"You have #{currency_symbol}#{current_cash.round(2)} now and approximately " \
"#{currency_symbol}#{upcoming_outflows.round(2)} in scheduled outflows over the next 30 days."
)
[
GeneratedInsight.new(
insight_type: "cash_flow_warning",
priority: low_balance < 0 ? "high" : "medium",
title: I18n.t("insights.cash_flow_warning.title",
date: low_date.strftime("%B %-d")),
body: body,
metadata: metadata,
currency: family.currency,
period_start: Date.current,
period_end: PROJECTION_DAYS.days.from_now.to_date,
dedup_key: "cash_flow_warning:#{Date.current.strftime("%Y-%m")}"
)
]
end
private
# Returns [{Date => Float}, Array<event_hash>]
def project_balance(current_cash)
events = build_recurring_events
daily_baseline = compute_daily_baseline
balance = current_cash
balance_by_date = {}
(0..PROJECTION_DAYS).each do |day_offset|
date = Date.current + day_offset
events.each do |event|
next unless event[:date] == date
if event[:type] == :inflow
balance += event[:amount]
else
balance -= event[:amount]
end
end
balance -= daily_baseline
balance_by_date[date] = balance
end
[ balance_by_date, events ]
end
def build_recurring_events
family.recurring_transactions.active
.where("next_expected_date <= ?", PROJECTION_DAYS.days.from_now.to_date)
.where("next_expected_date > ?", Date.current)
.includes(:merchant)
.filter_map do |rt|
display_amount = (rt.expected_amount_avg || rt.amount).to_f.abs
{
date: rt.next_expected_date,
amount: display_amount,
type: rt.amount.to_f > 0 ? :outflow : :inflow,
label: rt.merchant&.name || rt.name
}
end
end
def compute_daily_baseline
median = family.income_statement.median_expense(interval: "month").to_f
[ (median / 30.0).round(2), 0 ].max
end
end

View File

@@ -0,0 +1,47 @@
# Flags depository accounts with a material balance that has seen no transaction
# activity for an extended period — suggesting idle cash that could be earning more.
class Insight::Generators::IdleCashGenerator < Insight::Generator
IDLE_THRESHOLD_DAYS = 60
IDLE_AMOUNT_THRESHOLD = 5_000
def generate
depository_accounts = family.accounts.visible.assets.where(accountable_type: "Depository")
depository_accounts.filter_map do |account|
balance = account.balance.to_f
next unless balance >= IDLE_AMOUNT_THRESHOLD
recent_activity = account.entries
.where("date >= ?", IDLE_THRESHOLD_DAYS.days.ago.to_date)
.where(entryable_type: "Transaction")
.exists?
next if recent_activity
metadata = {
"account_id" => account.id,
"account_name" => account.name,
"idle_amount" => balance.round(2),
"idle_days" => IDLE_THRESHOLD_DAYS
}
body = generate_body(
"#{currency_symbol}#{balance.round(2)} has been sitting in #{account.name} " \
"for over #{IDLE_THRESHOLD_DAYS} days without any transactions. " \
"Consider whether this cash could be working in a higher-yield account."
)
GeneratedInsight.new(
insight_type: "idle_cash",
priority: "low",
title: I18n.t("insights.idle_cash.title", account: account.name),
body: body,
metadata: metadata,
currency: family.currency,
period_start: IDLE_THRESHOLD_DAYS.days.ago.to_date,
period_end: Date.current,
dedup_key: "idle_cash:#{account.id}:#{Date.current.strftime("%Y-%m")}"
)
end
end
end

View File

@@ -0,0 +1,93 @@
# Surfaces an insight when net worth crosses a round-number milestone or hits an all-time high
# over the past 30 days.
class Insight::Generators::NetWorthMilestoneGenerator < Insight::Generator
ROUND_MILESTONES = [
1_000, 5_000, 10_000, 25_000, 50_000,
100_000, 250_000, 500_000, 1_000_000
].freeze
def generate
balance_sheet = family.balance_sheet
current_nw = balance_sheet.net_worth.to_f
prior_period = Period.custom(
start_date: 30.days.ago.to_date,
end_date: 1.day.ago.to_date
)
prior_series = balance_sheet.net_worth_series(period: prior_period)
return [] if prior_series.nil?
prior_values = extract_prior_values(prior_series)
return [] if prior_values.empty?
prior_nw = prior_values.last.to_f
series_high = prior_values.max.to_f
all_time_high = current_nw >= series_high && current_nw > prior_nw
crossed = ROUND_MILESTONES.find { |m| current_nw >= m && prior_nw < m }
return [] unless crossed || all_time_high
metadata = {
"milestone" => crossed,
"current_net_worth" => current_nw.round(2),
"previous_net_worth" => prior_nw.round(2),
"all_time_high" => all_time_high
}
title = if all_time_high && crossed
I18n.t("insights.net_worth_milestone.title_both",
milestone: format_amount(crossed))
elsif all_time_high
I18n.t("insights.net_worth_milestone.title_ath")
else
I18n.t("insights.net_worth_milestone.title_milestone",
milestone: format_amount(crossed))
end
body = generate_body(build_prompt(crossed, all_time_high, current_nw, prior_nw))
[
GeneratedInsight.new(
insight_type: "net_worth_milestone",
priority: "high",
title: title,
body: body,
metadata: metadata,
currency: family.currency,
period_start: prior_period.start_date,
period_end: Date.current,
dedup_key: "net_worth_milestone:#{crossed || "ath"}:#{Date.current.strftime("%Y-%m")}"
)
]
end
private
def extract_prior_values(series)
return [] unless series.respond_to?(:values)
series.values.filter_map do |point|
val = point.try(:trend)&.try(:current) || point.try(:value) || point.try(:amount)
val.to_f if val
end
end
def build_prompt(crossed, all_time_high, current_nw, prior_nw)
sym = currency_symbol
current_fmt = "#{sym}#{current_nw.round(0).to_s(:delimited)}"
prior_fmt = "#{sym}#{prior_nw.round(0).to_s(:delimited)}"
if all_time_high && crossed
"Net worth crossed #{sym}#{crossed.to_s(:delimited)} and hit an all-time high of #{current_fmt}."
elsif all_time_high
"Net worth hit an all-time high of #{current_fmt}, up from #{prior_fmt} 30 days ago."
else
"Net worth crossed the #{sym}#{crossed.to_s(:delimited)} milestone, now at #{current_fmt}."
end
end
def format_amount(amount)
"#{currency_symbol}#{amount.to_s(:delimited)}"
end
end

View File

@@ -0,0 +1,53 @@
# Surfaces an insight when the savings rate changes significantly vs the prior month.
# Savings rate = (income - expenses) / income.
class Insight::Generators::SavingsRateChangeGenerator < Insight::Generator
CHANGE_THRESHOLD = 0.05 # 5 percentage-point swing required to surface an insight
def generate
income_stmt = family.income_statement
current_period = Period.current_month_for(family)
prior_period = Period.last_month_for(family)
current_income = income_stmt.income_totals(period: current_period).total.to_f
current_expense = income_stmt.expense_totals(period: current_period).total.to_f
prior_income = income_stmt.income_totals(period: prior_period).total.to_f
prior_expense = income_stmt.expense_totals(period: prior_period).total.to_f
return [] if current_income.zero? || prior_income.zero?
current_rate = (current_income - current_expense) / current_income
prior_rate = (prior_income - prior_expense) / prior_income
change = current_rate - prior_rate
return [] if change.abs < CHANGE_THRESHOLD
direction = change > 0 ? "improved" : "declined"
metadata = {
"current_savings_rate" => (current_rate * 100).round(1),
"prior_savings_rate" => (prior_rate * 100).round(1),
"change_points" => (change * 100).round(1),
"direction" => direction
}
body = generate_body(
"Your savings rate #{direction} from #{(prior_rate * 100).round(1)}% last month " \
"to #{(current_rate * 100).round(1)}% this month."
)
[
GeneratedInsight.new(
insight_type: "savings_rate_change",
priority: change.abs >= 0.10 ? "high" : "medium",
title: I18n.t("insights.savings_rate_change.title", direction: direction),
body: body,
metadata: metadata,
currency: family.currency,
period_start: current_period.start_date,
period_end: current_period.end_date,
dedup_key: "savings_rate_change:#{Date.current.strftime("%Y-%m")}"
)
]
end
end

View File

@@ -0,0 +1,88 @@
# Surfaces categories whose spending is significantly above or below their 3-month rolling average.
# Uses the existing IncomeStatement analytics infrastructure — no custom SQL required.
class Insight::Generators::SpendingAnomalyGenerator < Insight::Generator
ANOMALY_THRESHOLD = 0.25 # 25% above/below baseline triggers an insight
MIN_BASELINE_SPEND = 50 # Ignore tiny categories (noise reduction)
def generate
baseline_period = Period.custom(
start_date: 3.months.ago.to_date.beginning_of_month,
end_date: 1.month.ago.to_date.end_of_month
)
current_period = Period.current_month_for(family)
income_stmt = family.income_statement
baseline_totals = income_stmt.expense_totals(period: baseline_period)
current_totals = income_stmt.expense_totals(period: current_period)
baseline_by_cat = baseline_totals.category_totals.index_by { |ct| ct.category.id }
current_by_cat = current_totals.category_totals.index_by { |ct| ct.category.id }
# Project partial-month spend to a full-month pace
elapsed_days = [ (Date.current - current_period.start_date).to_i + 1, 1 ].max
total_days = [ (current_period.end_date - current_period.start_date).to_i + 1, 1 ].max
pace_factor = total_days.to_f / elapsed_days
all_ids = (baseline_by_cat.keys + current_by_cat.keys).uniq
insights = all_ids.filter_map do |cat_id|
baseline_ct = baseline_by_cat[cat_id]
current_ct = current_by_cat[cat_id]
next unless baseline_ct
next if baseline_ct.category.subcategory?
next if baseline_ct.category.synthetic?
baseline_monthly = (baseline_ct.total.to_f / 3.0).round(2)
next if baseline_monthly < MIN_BASELINE_SPEND
current_actual = current_ct&.total.to_f || 0
current_paced = (current_actual * pace_factor).round(2)
change_ratio = (current_paced - baseline_monthly) / baseline_monthly
next if change_ratio.abs < ANOMALY_THRESHOLD
category = baseline_ct.category
direction = change_ratio > 0 ? "up" : "down"
pct = (change_ratio.abs * 100).round(0)
delta = (current_paced - baseline_monthly).abs.round(2)
metadata = {
"category_id" => cat_id,
"category_name" => category.name,
"current_amount" => current_paced,
"baseline_amount" => baseline_monthly,
"percent_change" => (change_ratio * 100).round(1),
"direction" => direction,
"delta_amount" => delta
}
priority = change_ratio.abs >= 0.50 ? "high" : "medium"
body = generate_body(
"#{category.name} spending is #{direction} #{pct}% vs the 3-month average. " \
"Current pace: #{currency_symbol}#{current_paced}. " \
"Average: #{currency_symbol}#{baseline_monthly}. " \
"Difference: #{currency_symbol}#{delta}."
)
GeneratedInsight.new(
insight_type: "spending_anomaly",
priority: priority,
title: I18n.t("insights.spending_anomaly.title",
category: category.name,
direction: direction,
pct: "#{pct}%"),
body: body,
metadata: metadata,
currency: family.currency,
period_start: current_period.start_date,
period_end: current_period.end_date,
dedup_key: "spending_anomaly:#{cat_id}:#{Date.current.strftime("%Y-%m")}"
)
end
insights.sort_by { |i| -i.metadata["percent_change"].abs }.first(3)
end
end

View File

@@ -0,0 +1,48 @@
# Identifies recurring transactions that are overdue — meaning they haven't appeared
# in the family's entries for longer than expected. This signals a subscription may
# have been cancelled, changed, or is otherwise worth reviewing.
class Insight::Generators::SubscriptionAuditGenerator < Insight::Generator
OVERDUE_DAYS = 45 # days past last_occurrence_date before we flag it
def generate
stale = family.recurring_transactions
.active
.where("last_occurrence_date < ?", OVERDUE_DAYS.days.ago.to_date)
.where("next_expected_date < ?", Date.current)
.includes(:merchant)
.limit(5)
return [] if stale.empty?
stale.map do |rt|
display_name = rt.merchant&.name || rt.name
monthly_cost = (rt.expected_amount_avg || rt.amount).to_f.abs
metadata = {
"recurring_transaction_id" => rt.id,
"merchant_name" => display_name,
"monthly_cost" => monthly_cost.round(2),
"last_seen_date" => rt.last_occurrence_date.iso8601,
"days_overdue" => (Date.current - rt.last_occurrence_date).to_i
}
body = generate_body(
"#{display_name} (#{currency_symbol}#{monthly_cost.round(2)}/month) " \
"hasn't appeared in your transactions since #{rt.last_occurrence_date.strftime("%B %-d")}. " \
"It may have been cancelled or changed."
)
GeneratedInsight.new(
insight_type: "subscription_audit",
priority: "medium",
title: I18n.t("insights.subscription_audit.title", name: display_name),
body: body,
metadata: metadata,
currency: family.currency,
period_start: rt.last_occurrence_date,
period_end: Date.current,
dedup_key: "subscription_audit:#{rt.id}:#{Date.current.strftime("%Y-%m")}"
)
end
end
end

View File

@@ -129,6 +129,9 @@ class Setting < RailsSettings::Base
field :auto_sync_time, type: :string, default: ENV.fetch("AUTO_SYNC_TIME", "02:22")
field :auto_sync_timezone, type: :string, default: ENV.fetch("AUTO_SYNC_TIMEZONE", "UTC")
# Proactive financial insights feed (opt-in via INSIGHTS_ENABLED=1 env var or admin toggle)
field :insights_enabled, type: :boolean, default: ENV.fetch("INSIGHTS_ENABLED", "0") == "1"
AUTO_SYNC_TIME_FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
def self.valid_auto_sync_time?(time_str)

View File

@@ -164,6 +164,10 @@ class User < ApplicationRecord
ai_enabled && ai_available?
end
def insights_enabled?
Setting.insights_enabled
end
def self.default_ui_layout
layout = Rails.application.config.x.ui&.default_layout || "dashboard"
layout.in?(%w[intro dashboard]) ? layout : "dashboard"

View File

@@ -0,0 +1,25 @@
<%# locals: (insight:, compact: false) %>
<%= turbo_frame_tag dom_id(insight) do %>
<div class="rounded-xl border border-primary p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<%= icon(insight_icon(insight.insight_type), size: "sm", class: insight_icon_color_class(insight.priority)) %>
<p class="text-sm font-semibold text-primary truncate"><%= insight.title %></p>
</div>
<%= button_to t("insights.dismiss"),
dismiss_insight_path(insight),
method: :patch,
class: "text-secondary hover:text-primary text-xs shrink-0" %>
</div>
<p class="text-sm text-secondary leading-relaxed"><%= insight.body %></p>
<% unless compact %>
<p class="text-xs text-tertiary">
<%= t("insights.generated_at", time: time_ago_in_words(insight.generated_at)) %>
</p>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,30 @@
<% content_for :page_header do %>
<header class="flex justify-between items-center text-primary font-medium gap-4 mb-6">
<div class="space-y-1">
<h1 class="text-xl lg:text-3xl font-medium text-primary">
<%= t("insights.index.title") %>
</h1>
<p class="text-sm lg:text-base text-secondary">
<%= t("insights.index.subtitle") %>
</p>
</div>
<%= button_to t("insights.index.refresh"), refresh_insights_path,
method: :post,
class: "font-medium whitespace-nowrap inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-primary border border-secondary bg-transparent hover:bg-surface-hover" %>
</header>
<% end %>
<div class="max-w-2xl space-y-3">
<% if @insights.any? %>
<% @insights.each do |insight| %>
<%= render "insights/insight_card", insight: insight, compact: false %>
<% end %>
<% else %>
<div class="bg-container rounded-xl p-12 text-center space-y-3">
<%= render DS::FilledIcon.new(variant: :container, icon: "lightbulb") %>
<p class="text-primary font-medium"><%= t("insights.empty.title") %></p>
<p class="text-secondary text-sm"><%= t("insights.empty.body") %></p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,16 @@
<%# locals: (insights:, **) %>
<div class="px-4 space-y-3">
<% if insights.any? %>
<% insights.each do |insight| %>
<%= render "insights/insight_card", insight: insight, compact: true %>
<% end %>
<%= link_to t("insights.view_all"), insights_path,
class: "text-sm text-accent hover:text-accent font-medium" %>
<% else %>
<p class="text-secondary text-sm py-4 text-center">
<%= t("insights.empty_dashboard") %>
</p>
<% end %>
</div>

View File

@@ -0,0 +1,41 @@
---
en:
insights:
index:
title: Insights
subtitle: Personalized observations about your finances, updated daily
refresh: Refresh
view_all: View all insights
empty_dashboard: Your first insights will appear here after your accounts sync
empty:
title: No new insights
body: We'll analyze your data overnight and surface anything worth your attention
refresh_queued: Generating fresh insights — check back in a minute
generated_at: Generated %{time} ago
dismiss: Dismiss
priority:
high: High
medium: Medium
low: Low
spending_anomaly:
title: "%{category} spending %{direction} %{pct}"
cash_flow_warning:
title: Low cash projected around %{date}
net_worth_milestone:
title_ath: New all-time high net worth
title_milestone: Net worth crossed %{milestone}
title_both: Net worth crossed %{milestone} — new all-time high
subscription_audit:
title: Is %{name} still active?
savings_rate_change:
title: Savings rate %{direction} this month
idle_cash:
title: Idle cash in %{account}
budget_on_track:
title: Budget on track this month
budget_at_risk:
title: Budget at risk — spending ahead of pace
pages:
dashboard:
insights_feed:
title: Insights

View File

@@ -227,6 +227,16 @@ Rails.application.routes.draw do
delete :destroy_all, on: :collection
end
resources :insights, only: %i[index] do
member do
patch :read
patch :dismiss
end
collection do
post :refresh
end
end
resources :reports, only: %i[index] do
patch :update_preferences, on: :collection
get :export_transactions, on: :collection

View File

@@ -42,3 +42,9 @@ refresh_demo_family:
class: "DemoFamilyRefreshJob"
queue: "scheduled"
description: "Refreshes demo family data and emails super admins with daily usage summary"
generate_insights:
cron: "0 6 * * *" # daily at 6:00 AM UTC — after data syncs and nightly cleanup jobs
class: "GenerateInsightsJob"
queue: "scheduled"
description: "Generates proactive financial insights for all families"

View File

@@ -0,0 +1,32 @@
class CreateInsights < ActiveRecord::Migration[7.2]
def change
create_table :insights, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :insight_type, null: false
t.string :priority, null: false, default: "medium"
t.string :status, null: false, default: "active"
t.string :title, null: false
t.text :body, null: false
t.jsonb :metadata, null: false, default: {}
t.string :currency, null: false, default: "USD"
t.date :period_start
t.date :period_end
t.datetime :generated_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
t.datetime :read_at
t.datetime :dismissed_at
# Prevents re-inserting the same insight type for the same period;
# also used to detect whether numbers changed enough to reactivate a dismissed insight.
t.string :dedup_key, null: false
t.timestamps
end
add_index :insights, [ :family_id, :status ]
add_index :insights, [ :family_id, :generated_at ]
add_index :insights, [ :family_id, :dedup_key ], unique: true
end
end

75
test/fixtures/insights.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
spending_anomaly_dining:
family: dylan_family
insight_type: spending_anomaly
priority: medium
status: active
title: "Dining spending up 34%"
body: "Your dining spending is up 34% vs your 3-month average — currently on pace for $634 this month versus the $473 average."
metadata:
category_name: "Dining"
percent_change: 34.1
current_amount: 634.50
baseline_amount: 472.80
direction: "up"
delta_amount: 161.70
currency: USD
period_start: <%= Date.current.beginning_of_month %>
period_end: <%= Date.current.end_of_month %>
dedup_key: "spending_anomaly:dining-fixture:2026-04"
generated_at: <%= 1.hour.ago %>
cash_flow_warning:
family: dylan_family
insight_type: cash_flow_warning
priority: high
status: active
title: "Low cash projected around April 28"
body: "Based on your recurring bills and spending patterns, your cash balance may drop to $142 around April 28."
metadata:
projected_low_balance: 142.00
projected_low_date: "2026-04-28"
current_balance: 1800.00
upcoming_outflows: 1847.00
account_count: 1
currency: USD
period_start: <%= Date.current %>
period_end: <%= 30.days.from_now.to_date %>
dedup_key: "cash_flow_warning:2026-04"
generated_at: <%= 1.hour.ago %>
net_worth_milestone:
family: dylan_family
insight_type: net_worth_milestone
priority: high
status: read
title: "Net worth crossed $100,000"
body: "Net worth crossed the $100,000 milestone, now at $101,342."
metadata:
milestone: 100000
current_net_worth: 101342.00
previous_net_worth: 98800.00
all_time_high: true
currency: USD
period_start: <%= 30.days.ago.to_date %>
period_end: <%= Date.current %>
dedup_key: "net_worth_milestone:100000:2026-04"
generated_at: <%= 2.hours.ago %>
read_at: <%= 30.minutes.ago %>
dismissed_insight:
family: dylan_family
insight_type: idle_cash
priority: low
status: dismissed
title: "Idle cash in Savings"
body: "$6,200 has been sitting in Savings for over 60 days without any transactions."
metadata:
account_name: "Savings"
idle_amount: 6200.00
idle_days: 60
currency: USD
period_start: <%= 60.days.ago.to_date %>
period_end: <%= Date.current %>
dedup_key: "idle_cash:savings-fixture:2026-04"
generated_at: <%= 3.hours.ago %>
dismissed_at: <%= 1.hour.ago %>

View File

@@ -0,0 +1,82 @@
require "test_helper"
class GenerateInsightsJobTest < ActiveJob::TestCase
setup do
@family = families(:dylan_family)
end
test "performs without error for a family with no accounts" do
empty_family = families(:empty)
assert_nothing_raised do
GenerateInsightsJob.new.perform(family_id: empty_family.id)
end
end
test "performs without error when family_id is missing" do
assert_nothing_raised do
GenerateInsightsJob.new.perform(family_id: "nonexistent-id")
end
end
test "does not create duplicate insights on repeated runs" do
# Stub all generators to return a deterministic insight so we don't need real data
fixed_insight = Insight::Generator::GeneratedInsight.new(
insight_type: "net_worth_milestone",
priority: "high",
title: "Net worth milestone",
body: "You hit a milestone.",
metadata: { "milestone" => 100_000 },
currency: @family.currency,
period_start: 30.days.ago.to_date,
period_end: Date.current,
dedup_key: "net_worth_milestone:test:#{Date.current.strftime("%Y-%m")}"
)
Insight::GeneratorRegistry.stubs(:generate_for).returns([ fixed_insight ])
assert_difference "@family.insights.count", 1 do
GenerateInsightsJob.new.perform(family_id: @family.id)
end
# Second run — same dedup_key — should upsert, not create a new record
assert_no_difference "@family.insights.count" do
GenerateInsightsJob.new.perform(family_id: @family.id)
end
end
test "updates existing insight body and generated_at on repeated runs" do
dedup = "net_worth_milestone:update_test:#{Date.current.strftime("%Y-%m")}"
@family.insights.create!(
insight_type: "net_worth_milestone",
priority: "high",
status: "active",
title: "Old title",
body: "Old body",
metadata: { "milestone" => 50_000 },
currency: @family.currency,
period_start: 30.days.ago.to_date,
period_end: Date.current,
dedup_key: dedup,
generated_at: 1.day.ago
)
updated_insight = Insight::Generator::GeneratedInsight.new(
insight_type: "net_worth_milestone",
priority: "high",
title: "New title",
body: "New body",
metadata: { "milestone" => 100_000 },
currency: @family.currency,
period_start: 30.days.ago.to_date,
period_end: Date.current,
dedup_key: dedup
)
Insight::GeneratorRegistry.stubs(:generate_for).returns([ updated_insight ])
GenerateInsightsJob.new.perform(family_id: @family.id)
refreshed = @family.insights.find_by(dedup_key: dedup)
assert_equal "New title", refreshed.title
assert_equal "New body", refreshed.body
end
end

View File

@@ -0,0 +1,83 @@
require "test_helper"
class InsightTest < ActiveSupport::TestCase
test "mark_read! transitions active to read and sets read_at" do
insight = insights(:spending_anomaly_dining)
assert insight.active?
assert_nil insight.read_at
insight.mark_read!
assert insight.read?
assert_not_nil insight.read_at
end
test "mark_read! is a no-op when already read" do
insight = insights(:net_worth_milestone)
assert insight.read?
original_read_at = insight.read_at
insight.mark_read!
assert_equal original_read_at, insight.reload.read_at
end
test "dismiss! transitions to dismissed and sets dismissed_at" do
insight = insights(:spending_anomaly_dining)
assert insight.active?
insight.dismiss!
assert insight.dismissed?
assert_not_nil insight.dismissed_at
end
test "visible scope excludes dismissed insights" do
dismissed = insights(:dismissed_insight)
assert_not Insight.visible.include?(dismissed)
end
test "visible scope includes active and read insights" do
active = insights(:spending_anomaly_dining)
read = insights(:net_worth_milestone)
assert Insight.visible.include?(active)
assert Insight.visible.include?(read)
end
test "ordered scope places high priority before medium before low" do
high_insight = insights(:cash_flow_warning) # high
medium_insight = insights(:spending_anomaly_dining) # medium
ordered = Insight.where(family: families(:dylan_family)).visible.ordered
high_idx = ordered.index(high_insight)
medium_idx = ordered.index(medium_insight)
assert_not_nil high_idx
assert_not_nil medium_idx
assert high_idx < medium_idx
end
test "dedup_key uniqueness is enforced per family" do
original = insights(:spending_anomaly_dining)
duplicate = Insight.new(
family: original.family,
insight_type: original.insight_type,
priority: "low",
status: "active",
title: "Duplicate",
body: "Duplicate body",
metadata: {},
currency: "USD",
dedup_key: original.dedup_key,
generated_at: Time.current
)
assert_raises(ActiveRecord::RecordNotUnique) { duplicate.save!(validate: false) }
end
test "for_dashboard scope returns at most 3 visible insights" do
assert Insight.for_dashboard.count <= 3
end
end