Files
sure/app/helpers/simplefin_items_helper.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

54 lines
1.9 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frozen_string_literal: true
# View helpers for SimpleFin UI rendering
module SimplefinItemsHelper
# Builds a compact tooltip text summarizing sync errors from a stats hash.
# The stats structure comes from SimplefinItem::Importer and Sync records.
# Returns nil when there is nothing meaningful to display.
#
# Example structure:
# {
# "total_errors" => 3,
# "errors" => [ { "name" => "Chase", "message" => "Timeout" }, ... ],
# "error_buckets" => { "auth" => 1, "api" => 2 }
# }
def simplefin_error_tooltip(stats)
return nil unless stats.is_a?(Hash)
total_errors = stats["total_errors"].to_i
return nil if total_errors.zero?
# Build a small, de-duplicated sample of messages with counts
grouped = Array(stats["errors"]).map { |e|
name = (e[:name] || e["name"]).to_s
msg = (e[:message] || e["message"]).to_s
text = name.present? ? "#{name}: #{msg}" : msg
text.strip
}.reject(&:blank?).tally
sample = grouped.first(2).map { |text, count| count > 1 ? "#{text} (×#{count})" : text }.join("")
buckets = stats["error_buckets"] || {}
bucket_text = if buckets.present?
buckets.map { |k, v| "#{k}: #{v}" }.join(", ")
end
parts = [ "Errors: ", total_errors.to_s ]
parts << " (#{bucket_text})" if bucket_text.present?
parts << "#{sample}" if sample.present?
parts.join
end
# Human-friendly relative-time phrase for an activity badge. Returns nil for
# a nil input so callers can fall through to "no activity" copy.
def activity_when(time, now: Time.current)
return nil if time.blank?
days = ((now.to_i - time.to_i) / 86_400).floor
case days
when ..0 then t("simplefin_items.setup_accounts.activity.today")
when 1 then t("simplefin_items.setup_accounts.activity.yesterday")
else t("simplefin_items.setup_accounts.activity.days_ago", count: days)
end
end
end