class SimplefinItem # Detects cases where a linked SimpleFIN account looks like it has been # replaced by a new unlinked SimpleFIN account at the same institution # (typical for credit-card fraud replacement: the bank closes the old card # and issues a new one, so SimpleFIN returns both for a transition window). # # Heuristic: # * dormant_sfa: linked to a Sure account, no activity in 45+ days, # AND near-zero current balance. # * active_sfa: unlinked, recently active (transactions in last 30 days), # belongs to the same simplefin_item, # same account_type and same organisation name as dormant_sfa. # * pair: exactly one active_sfa matches. Two or more candidates # are considered ambiguous and skipped to avoid a wrong # auto-suggestion. # # The detector does NOT mutate any records. It returns a plain array of # suggestion hashes which the caller (Importer) persists on sync_stats so # the UI can render a prompt. class ReplacementDetector DORMANCY_DAYS = 45 ACTIVE_WINDOW_DAYS = 30 NEAR_ZERO_BALANCE = BigDecimal("1.00") # Fraud-replacement is overwhelmingly a credit-card pattern (old card closed, # new card issued with same institution/metadata). Checking/savings-account # replacement exists but has very different UX cues (e.g., users get a new # account number in advance). Scope narrowly for now; broaden later with # account-type-aware copy if demand materialises. SUPPORTED_ACCOUNT_TYPES = %w[credit credit_card creditcard].freeze def initialize(simplefin_item) @simplefin_item = simplefin_item end # @return [Array] suggestions. Empty when no replacements detected. def call sfas = @simplefin_item.simplefin_accounts .includes(:linked_account, :account) .to_a .select { |sfa| supported_type?(sfa) } active_unlinked = sfas.select { |sfa| unlinked?(sfa) && active?(sfa) } return [] if active_unlinked.empty? # First pass: for each dormant candidate, find unambiguous matching actives # (exactly one). Rejects "one dormant → many actives" collisions. candidates = sfas.filter_map do |dormant| next unless linked?(dormant) && dormant_with_zero_balance?(dormant) matches = active_unlinked.select { |sfa| same_institution_and_type?(dormant, sfa) } next if matches.size != 1 [ dormant, matches.first ] end # Second pass: reject "many dormants → one active" collisions. If two # dormant accounts both claim the same active, we can't safely auto-suggest # either — relinking both would move the provider away from the first. active_counts = candidates.each_with_object(Hash.new(0)) { |(_d, a), h| h[a.id] += 1 } candidates.filter_map do |dormant, active| next if active_counts[active.id] > 1 build_suggestion(dormant: dormant, active: active) end end private def supported_type?(sfa) SUPPORTED_ACCOUNT_TYPES.include?(canonical_account_type(sfa)) end # Canonicalize for both gating (supported_type?) and matching # (type_matches?) so variants like "credit card" and "credit_card" # round-trip to the same key. def canonical_account_type(sfa) sfa.account_type.to_s.downcase.gsub(/\s+/, "_") end def linked?(sfa) sfa.current_account.present? end def unlinked?(sfa) sfa.current_account.blank? end def dormant_with_zero_balance?(sfa) # Require evidence of prior activity. An empty payload carries no signal # (e.g., a brand-new card just linked) and must not trigger a replacement # suggestion. Matches the likely-closed gate used by the setup UI. return false if sfa.activity_summary.last_transacted_at.blank? return false unless sfa.activity_summary.dormant?(days: DORMANCY_DAYS) # Missing current_balance is "unknown," not "zero." Treat it as evidence # against replacement rather than for it. return false if sfa.current_balance.nil? sfa.current_balance.to_d.abs <= NEAR_ZERO_BALANCE end def active?(sfa) sfa.activity_summary.recently_active?(days: ACTIVE_WINDOW_DAYS) end def same_institution_and_type?(a, b) type_matches?(a, b) && org_matches?(a, b) end def type_matches?(a, b) canonical_account_type(a) == canonical_account_type(b) end # Require BOTH sides to have a non-blank org name. SimpleFIN sometimes omits # org_data.name; "" casecmp? "" would otherwise treat unrelated accounts as # co-institutional, producing false replacement suggestions. def org_matches?(a, b) name_a = org_name(a) name_b = org_name(b) return false if name_a.blank? || name_b.blank? name_a.casecmp?(name_b) end def org_name(sfa) name = sfa.org_data.is_a?(Hash) ? (sfa.org_data["name"] || sfa.org_data[:name]) : nil name.to_s.strip end def build_suggestion(dormant:, active:) { "dormant_sfa_id" => dormant.id, "active_sfa_id" => active.id, "sure_account_id" => dormant.current_account&.id, "institution_name" => org_name(dormant), "dormant_account_name" => dormant.name, "active_account_name" => active.name, "confidence" => "high" } end end end