mirror of
https://github.com/we-promise/sure.git
synced 2026-05-08 05:04:59 +00:00
* SimpleFIN: setup UX + same-provider relink + card-replacement detection Fixes three bugs and adds auto-detection for credit-card fraud replacement. Bugs: - Importer: per-institution auth errors no longer flip the whole item to requires_update. Partial errors stay on sync_stats so other institutions keep syncing. - Setup page: new activity badges (recent / dormant / empty / likely-closed) via SimplefinAccount::ActivitySummary. Likely-closed (dormant + near-zero balance + prior history) defaults to "skip" in the type picker. - Relink: link_existing_account allows SimpleFIN to SimpleFIN swaps by atomically detaching the old AccountProvider inside a transaction. Adds "Change SimpleFIN account" menu item on linked-account dropdowns. Feature (credit-card scope only): - SimplefinItem::ReplacementDetector runs post-sync. Pairs a linked dormant zero-balance sfa with an unlinked active sfa at the same institution and account type. Persists suggestions on Sync#sync_stats. - Inline banner on the SimpleFIN item card prompts relink via CustomConfirm. Per-pair dismiss button scoped to the current sync (resurfaces on next sync if still applicable). Auto-suppresses once the relink has landed. Dev tooling: - bin/rails simplefin:seed_fraud_scenario[email] creates a realistic broken pair for manual QA; cleanup_fraud_scenario reverses it. * Address review feedback on #1493 - ReplacementDetector: symmetric one-to-one matching. Two dormant cards pointing at the same active card are now both skipped — previously the detector could emit two suggestions that would clobber each other if the user accepted both. - ReplacementDetector: require non-blank institution names on both sides before matching. Blank-vs-blank was accidentally treated as equal, risking cross-provider false matches when SimpleFIN omitted org_data. - ActivitySummary: fall back to "posted" when "transacted_at" is 0 (SimpleFIN's "unknown" sentinel). Integer 0 is truthy in Ruby, so the previous `|| fallback` short-circuited and ignored posted. - Controller: dismiss key is now the (dormant, active) pair so dismissing one candidate for a dormant card doesn't suppress others. - Helper test: freeze time around "6.hours.ago" and "5.days.ago" assertions so they don't flake when the suite runs before 06:00. * Address second review pass on #1493 - ReplacementDetector: canonicalize account_type in one place so filtering (supported_type?) and matching (type_matches?) agree on "credit card" vs "credit_card" variants. - ReplacementDetector: skip candidates with nil current_balance. nil is "unknown," not "zero" — previously fell back to 0 and passed the near- zero gate, allowing suggestions without balance evidence.
260 lines
12 KiB
Ruby
260 lines
12 KiB
Ruby
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
|