mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 12:54:04 +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.
136 lines
5.5 KiB
Ruby
136 lines
5.5 KiB
Ruby
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<Hash>] 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
|