mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 12:54:04 +00:00
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:
39
app/controllers/insights_controller.rb
Normal file
39
app/controllers/insights_controller.rb
Normal 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
|
||||
@@ -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",
|
||||
|
||||
28
app/helpers/insights_helper.rb
Normal file
28
app/helpers/insights_helper.rb
Normal 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
|
||||
62
app/jobs/generate_insights_job.rb
Normal file
62
app/jobs/generate_insights_job.rb
Normal 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
|
||||
@@ -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
41
app/models/insight.rb
Normal 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
|
||||
64
app/models/insight/generator.rb
Normal file
64
app/models/insight/generator.rb
Normal 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
|
||||
22
app/models/insight/generator_registry.rb
Normal file
22
app/models/insight/generator_registry.rb
Normal 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
|
||||
69
app/models/insight/generators/budget_insight_generator.rb
Normal file
69
app/models/insight/generators/budget_insight_generator.rb
Normal 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
|
||||
101
app/models/insight/generators/cash_flow_warning_generator.rb
Normal file
101
app/models/insight/generators/cash_flow_warning_generator.rb
Normal 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
|
||||
47
app/models/insight/generators/idle_cash_generator.rb
Normal file
47
app/models/insight/generators/idle_cash_generator.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
88
app/models/insight/generators/spending_anomaly_generator.rb
Normal file
88
app/models/insight/generators/spending_anomaly_generator.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
25
app/views/insights/_insight_card.html.erb
Normal file
25
app/views/insights/_insight_card.html.erb
Normal 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 %>
|
||||
30
app/views/insights/index.html.erb
Normal file
30
app/views/insights/index.html.erb
Normal 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>
|
||||
16
app/views/pages/dashboard/_insights_feed.html.erb
Normal file
16
app/views/pages/dashboard/_insights_feed.html.erb
Normal 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>
|
||||
41
config/locales/views/insights/en.yml
Normal file
41
config/locales/views/insights/en.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
32
db/migrate/20260412120000_create_insights.rb
Normal file
32
db/migrate/20260412120000_create_insights.rb
Normal 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
75
test/fixtures/insights.yml
vendored
Normal 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 %>
|
||||
82
test/jobs/generate_insights_job_test.rb
Normal file
82
test/jobs/generate_insights_job_test.rb
Normal 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
|
||||
83
test/models/insight_test.rb
Normal file
83
test/models/insight_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user