mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +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.
68 lines
2.1 KiB
Ruby
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
|