mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02: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.
54 lines
1.9 KiB
Ruby
54 lines
1.9 KiB
Ruby
# 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
|