Files
sure/app/models/simplefin_account/activity_summary.rb
LPW 0a96bf199d SimpleFIN: setup UX + same-provider relink + card-replacement detection (#1493)
* 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.
2026-04-18 09:50:34 +02:00

68 lines
2.1 KiB
Ruby

class SimplefinAccount
# Value object summarising the activity state of a SimpleFIN account's raw
# transactions payload. Used by the setup UI to help users distinguish live
# from dormant accounts, and by the ReplacementDetector to spot cards that
# have likely been replaced.
class ActivitySummary
DEFAULT_WINDOW_DAYS = 60
def initialize(transactions)
@transactions = Array(transactions).compact
end
def last_transacted_at
return @last_transacted_at if defined?(@last_transacted_at)
@last_transacted_at = @transactions.filter_map { |tx| transacted_at(tx) }.max
end
def days_since_last_activity(now: Time.current)
return nil unless last_transacted_at
((now.to_i - last_transacted_at.to_i) / 86_400).floor
end
def recent_transaction_count(days: DEFAULT_WINDOW_DAYS)
cutoff = days.days.ago
@transactions.count { |tx| (ts = transacted_at(tx)) && ts >= cutoff }
end
def recently_active?(days: DEFAULT_WINDOW_DAYS)
recent_transaction_count(days: days).positive?
end
def dormant?(days: DEFAULT_WINDOW_DAYS)
!recently_active?(days: days)
end
def transaction_count
@transactions.size
end
private
# Extract a Time for sorting/windowing. Prefer transacted_at (SimpleFIN
# authored timestamp), fall back to posted. Zero values mean "unknown"
# in SimpleFIN (e.g., pending transactions have posted=0) and are ignored.
# Note: integer 0 is truthy in Ruby, so a plain `|| fallback` short-circuits
# and never falls back. Use explicit helper so transacted_at=0 properly
# yields to posted.
def transacted_at(tx)
return nil unless tx.is_a?(Hash) || tx.respond_to?(:[])
value = timestamp_value(fetch(tx, "transacted_at")) ||
timestamp_value(fetch(tx, "posted"))
return nil unless value
Time.at(value)
rescue StandardError
nil
end
def timestamp_value(raw)
return nil if raw.blank?
value = raw.to_i
value.zero? ? nil : value
end
def fetch(tx, key)
tx[key] || tx[key.to_sym]
end
end
end