require "test_helper" class SimplefinItem::ReplacementDetectorTest < ActiveSupport::TestCase setup do @family = families(:dylan_family) @item = SimplefinItem.create!( family: @family, name: "SF Conn", access_url: "https://example.com/access" ) end def make_sfa(name:, account_id:, account_type: "credit", org_name: "Citibank", balance: -100, transactions: []) @item.simplefin_accounts.create!( name: name, account_id: account_id, currency: "USD", account_type: account_type, current_balance: balance, org_data: { "name" => org_name }, raw_transactions_payload: transactions ) end def link(sfa, name:) account = @family.accounts.create!( name: name, balance: (sfa.current_balance || 0).to_d, currency: sfa.currency, accountable: CreditCard.create!(subtype: "credit_card") ) sfa.update!(account: account) account.update!(simplefin_account_id: sfa.id) account end def tx(when_ago:) { "id" => SecureRandom.hex(4), "transacted_at" => when_ago.ago.to_i, "posted" => when_ago.ago.to_i, "amount" => "-5" } end test "returns empty when simplefin_item has no accounts" do assert_equal [], SimplefinItem::ReplacementDetector.new(@item).call end test "returns empty when there are no active unlinked candidates" do dormant = make_sfa(name: "Citi Old", account_id: "sf_old", balance: 0, transactions: [ tx(when_ago: 90.days) ]) link(dormant, name: "Citi Double Cash") # No unlinked sfas at all assert_equal [], SimplefinItem::ReplacementDetector.new(@item).call end test "detects classic fraud replacement: dormant+zero linked sfa + active unlinked sfa same org+type" do dormant = make_sfa(name: "Citi-3831", account_id: "sf_3831", balance: 0, transactions: [ tx(when_ago: 45.days) ]) linked_account = link(dormant, name: "Citi Double Cash Card-3831") active = make_sfa(name: "Citi-2879", account_id: "sf_2879", balance: -1200, transactions: [ tx(when_ago: 2.days), tx(when_ago: 5.days) ]) suggestions = SimplefinItem::ReplacementDetector.new(@item).call assert_equal 1, suggestions.size suggestion = suggestions.first assert_equal dormant.id, suggestion["dormant_sfa_id"] assert_equal active.id, suggestion["active_sfa_id"] assert_equal linked_account.id, suggestion["sure_account_id"] assert_equal "Citibank", suggestion["institution_name"] assert_equal "high", suggestion["confidence"] end test "ignores candidates at different institutions" do dormant = make_sfa(name: "Citi-3831", account_id: "sf_old", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Citi Double Cash") # Active sfa at a DIFFERENT institution - not a real replacement make_sfa(name: "Chase-Freedom", account_id: "sf_chase", balance: -200, org_name: "Chase", transactions: [ tx(when_ago: 3.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "ignores candidates with different account_type" do dormant = make_sfa(name: "Citi-3831", account_id: "sf_old", account_type: "credit", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Citi Double Cash") # Active sfa at same institution but different type make_sfa(name: "Citi Checking", account_id: "sf_checking", account_type: "depository", balance: 500, transactions: [ tx(when_ago: 3.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "skips ambiguous matches (multiple candidates)" do dormant = make_sfa(name: "Citi-3831", account_id: "sf_old", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Citi Double Cash") # Two active unlinked Citi credit cards — can't tell which replaced it make_sfa(name: "Citi-2879", account_id: "sf_new1", balance: -100, transactions: [ tx(when_ago: 2.days) ]) make_sfa(name: "Citi-4567", account_id: "sf_new2", balance: -200, transactions: [ tx(when_ago: 5.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "ignores dormant sfa with non-zero balance (probably legitimate dormant account)" do # A savings account sitting at $5000 with no recent activity isn't # fraud replacement — it's just a savings account dormant = make_sfa(name: "Dormant Savings", account_id: "sf_savings", balance: 5000, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Savings Account") make_sfa(name: "Citi-2879", account_id: "sf_new", balance: -100, transactions: [ tx(when_ago: 2.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "ignores active sfa as the 'dormant' candidate" do # Account with both dormant-looking AND active (recent activity): not dormant active_linked = make_sfa(name: "Citi-3831", account_id: "sf_still_active", balance: 0, transactions: [ tx(when_ago: 3.days) ]) link(active_linked, name: "Citi Card") make_sfa(name: "Citi-2879", account_id: "sf_candidate", balance: -100, transactions: [ tx(when_ago: 2.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "suggestion uses case-insensitive org and type matching" do dormant = make_sfa(name: "Citi Old", account_id: "sf_old", balance: 0, account_type: "CREDIT", org_name: "CITIBANK", transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Citi Double Cash") active = make_sfa(name: "Citi New", account_id: "sf_new", balance: -100, account_type: "credit", org_name: "Citibank", transactions: [ tx(when_ago: 2.days) ]) suggestions = SimplefinItem::ReplacementDetector.new(@item).call assert_equal 1, suggestions.size assert_equal active.id, suggestions.first["active_sfa_id"] end test "detects multiple independent replacements across institutions" do # Two fraud replacements in the same sync: Citi + Chase both replaced dormant_citi = make_sfa(name: "Citi-old", account_id: "sf_c_old", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant_citi, name: "Citi") make_sfa(name: "Citi-new", account_id: "sf_c_new", balance: -100, transactions: [ tx(when_ago: 3.days) ]) dormant_chase = make_sfa(name: "Chase-old", account_id: "sf_ch_old", org_name: "Chase", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant_chase, name: "Chase") make_sfa(name: "Chase-new", account_id: "sf_ch_new", org_name: "Chase", balance: -200, transactions: [ tx(when_ago: 1.day) ]) suggestions = SimplefinItem::ReplacementDetector.new(@item).call assert_equal 2, suggestions.size orgs = suggestions.map { |s| s["institution_name"] }.sort assert_equal [ "Chase", "Citibank" ], orgs end test "ignores non-credit account types (checking, savings, investment)" do # Fraud-replacement UX is credit-card scoped for now. A depository/checking # pair that matches all other detector criteria must be skipped. dormant = make_sfa(name: "Old Checking", account_id: "sf_checking_old", account_type: "depository", org_name: "Chase", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Old Checking") make_sfa(name: "New Checking", account_id: "sf_checking_new", account_type: "depository", org_name: "Chase", balance: 1234, transactions: [ tx(when_ago: 2.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "does not emit multiple suggestions pointing at the same active sfa" do # Two dormant credit cards at the same institution, one new active card. # Relinking both would move the provider away from the first account. # Detector must skip both to avoid silent breakage. dormant1 = make_sfa(name: "Citi-OLD-1", account_id: "sf_old1", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant1, name: "Citi Card 1") dormant2 = make_sfa(name: "Citi-OLD-2", account_id: "sf_old2", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant2, name: "Citi Card 2") make_sfa(name: "Citi-NEW", account_id: "sf_new", balance: -100, transactions: [ tx(when_ago: 2.days) ]) suggestions = SimplefinItem::ReplacementDetector.new(@item).call assert_empty suggestions, "must not emit ambiguous pairs that reuse the same active sfa" end test "treats blank institution names as non-matching (not co-institutional)" do # SimpleFIN sometimes omits org_data.name. Two credit-card sfas with blank # org names must NOT be treated as at the same institution — otherwise any # dormant+active credit pair would auto-match regardless of provider. dormant = @item.simplefin_accounts.create!( name: "Mystery-OLD", account_id: "sf_mystery_old", currency: "USD", account_type: "credit", current_balance: 0, org_data: {}, raw_transactions_payload: [ tx(when_ago: 60.days) ] ) link(dormant, name: "Mystery") @item.simplefin_accounts.create!( name: "Mystery-NEW", account_id: "sf_mystery_new", currency: "USD", account_type: "credit", current_balance: -200, org_data: {}, raw_transactions_payload: [ tx(when_ago: 2.days) ] ) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "ignores dormant candidate when current_balance is unknown (nil)" do # nil balance is 'unknown,' not 'zero.' Treat as evidence against a match. # Model-level validation normally prevents nil current_balance but upstream # data has occasionally landed this way; simulate via `update_columns` to # bypass validation and assert the detector's robustness. dormant = make_sfa(name: "Citi-UNKNOWN-BAL", account_id: "sf_nil_bal", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Unknown Citi") dormant.update_columns(current_balance: nil, available_balance: nil) make_sfa(name: "New Citi", account_id: "sf_nil_new", balance: -100, transactions: [ tx(when_ago: 2.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end test "matches sfa pairs when account_type uses 'credit card' / 'credit_card' variants" do dormant = make_sfa(name: "Citi-OLD-var", account_id: "sf_var_old", account_type: "credit card", balance: 0, transactions: [ tx(when_ago: 60.days) ]) link(dormant, name: "Variant Citi") make_sfa(name: "Citi-NEW-var", account_id: "sf_var_new", account_type: "credit_card", balance: -100, transactions: [ tx(when_ago: 2.days) ]) suggestions = SimplefinItem::ReplacementDetector.new(@item).call assert_equal 1, suggestions.size, "canonicalized account_type should match across spacing variants" end test "ignores linked sfa with no transaction history (brand-new card, not dormant)" do # A newly linked card with zero balance and no transactions yet must NOT be # flagged as a replacement target. "Dormant" requires prior activity that # has since gone silent; an empty payload carries no such signal. fresh = make_sfa(name: "Brand New Citi", account_id: "sf_fresh", balance: 0, transactions: []) link(fresh, name: "Brand New Citi Card") make_sfa(name: "Other Citi", account_id: "sf_other", balance: -50, transactions: [ tx(when_ago: 2.days) ]) assert_empty SimplefinItem::ReplacementDetector.new(@item).call end end