diff --git a/app/controllers/insights_controller.rb b/app/controllers/insights_controller.rb new file mode 100644 index 000000000..4031a299f --- /dev/null +++ b/app/controllers/insights_controller.rb @@ -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 diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e0802c1c5..f849ea21b 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -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", diff --git a/app/helpers/insights_helper.rb b/app/helpers/insights_helper.rb new file mode 100644 index 000000000..bd2b4da0c --- /dev/null +++ b/app/helpers/insights_helper.rb @@ -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 diff --git a/app/jobs/generate_insights_job.rb b/app/jobs/generate_insights_job.rb new file mode 100644 index 000000000..90dc192c9 --- /dev/null +++ b/app/jobs/generate_insights_job.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index c141ec968..0db10a671 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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) } diff --git a/app/models/insight.rb b/app/models/insight.rb new file mode 100644 index 000000000..660cff622 --- /dev/null +++ b/app/models/insight.rb @@ -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 diff --git a/app/models/insight/generator.rb b/app/models/insight/generator.rb new file mode 100644 index 000000000..01fd0f49b --- /dev/null +++ b/app/models/insight/generator.rb @@ -0,0 +1,64 @@ +# Base class for all insight generators. +# +# Subclasses must implement #generate, returning an Array. +# 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 diff --git a/app/models/insight/generator_registry.rb b/app/models/insight/generator_registry.rb new file mode 100644 index 000000000..1d30ea3de --- /dev/null +++ b/app/models/insight/generator_registry.rb @@ -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 diff --git a/app/models/insight/generators/budget_insight_generator.rb b/app/models/insight/generators/budget_insight_generator.rb new file mode 100644 index 000000000..80ee656b3 --- /dev/null +++ b/app/models/insight/generators/budget_insight_generator.rb @@ -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 diff --git a/app/models/insight/generators/cash_flow_warning_generator.rb b/app/models/insight/generators/cash_flow_warning_generator.rb new file mode 100644 index 000000000..912a06bcb --- /dev/null +++ b/app/models/insight/generators/cash_flow_warning_generator.rb @@ -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] + 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 diff --git a/app/models/insight/generators/idle_cash_generator.rb b/app/models/insight/generators/idle_cash_generator.rb new file mode 100644 index 000000000..8fe72f6e9 --- /dev/null +++ b/app/models/insight/generators/idle_cash_generator.rb @@ -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 diff --git a/app/models/insight/generators/net_worth_milestone_generator.rb b/app/models/insight/generators/net_worth_milestone_generator.rb new file mode 100644 index 000000000..cb0be4947 --- /dev/null +++ b/app/models/insight/generators/net_worth_milestone_generator.rb @@ -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 diff --git a/app/models/insight/generators/savings_rate_change_generator.rb b/app/models/insight/generators/savings_rate_change_generator.rb new file mode 100644 index 000000000..94d22a1b6 --- /dev/null +++ b/app/models/insight/generators/savings_rate_change_generator.rb @@ -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 diff --git a/app/models/insight/generators/spending_anomaly_generator.rb b/app/models/insight/generators/spending_anomaly_generator.rb new file mode 100644 index 000000000..866b435f3 --- /dev/null +++ b/app/models/insight/generators/spending_anomaly_generator.rb @@ -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 diff --git a/app/models/insight/generators/subscription_audit_generator.rb b/app/models/insight/generators/subscription_audit_generator.rb new file mode 100644 index 000000000..1ff465645 --- /dev/null +++ b/app/models/insight/generators/subscription_audit_generator.rb @@ -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 diff --git a/app/models/setting.rb b/app/models/setting.rb index f16a66d6a..f7073265b 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -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) diff --git a/app/models/user.rb b/app/models/user.rb index 02320aa8d..d749af6f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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" diff --git a/app/views/insights/_insight_card.html.erb b/app/views/insights/_insight_card.html.erb new file mode 100644 index 000000000..5714aef01 --- /dev/null +++ b/app/views/insights/_insight_card.html.erb @@ -0,0 +1,25 @@ +<%# locals: (insight:, compact: false) %> + +<%= turbo_frame_tag dom_id(insight) do %> +
+
+
+ <%= icon(insight_icon(insight.insight_type), size: "sm", class: insight_icon_color_class(insight.priority)) %> +

<%= insight.title %>

+
+ + <%= button_to t("insights.dismiss"), + dismiss_insight_path(insight), + method: :patch, + class: "text-secondary hover:text-primary text-xs shrink-0" %> +
+ +

<%= insight.body %>

+ + <% unless compact %> +

+ <%= t("insights.generated_at", time: time_ago_in_words(insight.generated_at)) %> +

+ <% end %> +
+<% end %> diff --git a/app/views/insights/index.html.erb b/app/views/insights/index.html.erb new file mode 100644 index 000000000..4bbe090a9 --- /dev/null +++ b/app/views/insights/index.html.erb @@ -0,0 +1,30 @@ +<% content_for :page_header do %> +
+
+

+ <%= t("insights.index.title") %> +

+

+ <%= t("insights.index.subtitle") %> +

+
+ + <%= 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" %> +
+<% end %> + +
+ <% if @insights.any? %> + <% @insights.each do |insight| %> + <%= render "insights/insight_card", insight: insight, compact: false %> + <% end %> + <% else %> +
+ <%= render DS::FilledIcon.new(variant: :container, icon: "lightbulb") %> +

<%= t("insights.empty.title") %>

+

<%= t("insights.empty.body") %>

+
+ <% end %> +
diff --git a/app/views/pages/dashboard/_insights_feed.html.erb b/app/views/pages/dashboard/_insights_feed.html.erb new file mode 100644 index 000000000..9b4ca3fb7 --- /dev/null +++ b/app/views/pages/dashboard/_insights_feed.html.erb @@ -0,0 +1,16 @@ +<%# locals: (insights:, **) %> + +
+ <% 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 %> +

+ <%= t("insights.empty_dashboard") %> +

+ <% end %> +
diff --git a/config/locales/views/insights/en.yml b/config/locales/views/insights/en.yml new file mode 100644 index 000000000..ef8e634e0 --- /dev/null +++ b/config/locales/views/insights/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 5f723eed0..aaf714b22 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/schedule.yml b/config/schedule.yml index c3903a229..bd1ef44dd 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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" diff --git a/db/migrate/20260412120000_create_insights.rb b/db/migrate/20260412120000_create_insights.rb new file mode 100644 index 000000000..248b0e2a3 --- /dev/null +++ b/db/migrate/20260412120000_create_insights.rb @@ -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 diff --git a/test/fixtures/insights.yml b/test/fixtures/insights.yml new file mode 100644 index 000000000..73017e6f6 --- /dev/null +++ b/test/fixtures/insights.yml @@ -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 %> diff --git a/test/jobs/generate_insights_job_test.rb b/test/jobs/generate_insights_job_test.rb new file mode 100644 index 000000000..8dd708fd8 --- /dev/null +++ b/test/jobs/generate_insights_job_test.rb @@ -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 diff --git a/test/models/insight_test.rb b/test/models/insight_test.rb new file mode 100644 index 000000000..25d4df3a6 --- /dev/null +++ b/test/models/insight_test.rb @@ -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