mirror of
https://github.com/we-promise/sure.git
synced 2026-06-07 03:39:00 +00:00
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.
This commit is contained in:
129
lib/tasks/simplefin_dev.rake
Normal file
129
lib/tasks/simplefin_dev.rake
Normal file
@@ -0,0 +1,129 @@
|
||||
# Developer utilities for exercising the SimpleFIN setup/relink flows.
|
||||
# Safe only against development/test databases — never run against production.
|
||||
|
||||
namespace :simplefin do
|
||||
desc "Seed a card-replacement (fraud) scenario for the user with the given email"
|
||||
task :seed_fraud_scenario, [ :user_email ] => :environment do |_t, args|
|
||||
if Rails.env.production?
|
||||
abort("Refusing to run simplefin:seed_fraud_scenario in production")
|
||||
end
|
||||
|
||||
email = args[:user_email].presence ||
|
||||
ENV["USER_EMAIL"].presence ||
|
||||
abort("Usage: bin/rails 'simplefin:seed_fraud_scenario[user@example.com]'")
|
||||
|
||||
user = User.find_by!(email: email)
|
||||
family = user.family
|
||||
puts "Seeding fraud scenario for #{user.email} (family: #{family.id})"
|
||||
|
||||
# Piggyback on an existing SimpleFIN item when the family already has one,
|
||||
# so the seeded pair renders inside that card (matching how real fraud
|
||||
# replacements appear: both cards come from the same institution's item).
|
||||
# Fall back to a dedicated seed item otherwise.
|
||||
item = family.simplefin_items.where.not(name: "Dev Fraud Scenario").first
|
||||
if item
|
||||
puts " Attaching to existing item: #{item.name} (#{item.id})"
|
||||
else
|
||||
item = family.simplefin_items.create!(
|
||||
name: "Dev Fraud Scenario",
|
||||
access_url: "https://example.com/seed/#{SecureRandom.hex(4)}"
|
||||
)
|
||||
puts " Created standalone item: #{item.name} (#{item.id})"
|
||||
end
|
||||
|
||||
old_sfa = item.simplefin_accounts.create!(
|
||||
name: "Citi Double Cash Card-OLD (9999)",
|
||||
account_id: "seed_citi_old_#{SecureRandom.hex(3)}",
|
||||
currency: "USD",
|
||||
account_type: "credit",
|
||||
current_balance: 0,
|
||||
org_data: { "name" => "Citibank" },
|
||||
raw_transactions_payload: [
|
||||
{
|
||||
"id" => "seed_old_tx_1",
|
||||
"transacted_at" => 60.days.ago.to_i,
|
||||
"posted" => 60.days.ago.to_i,
|
||||
"amount" => "-42.50",
|
||||
"payee" => "Coffee Shop"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
sure_account = family.accounts.create!(
|
||||
name: "Citi Double Cash (dev seed)",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: CreditCard.create!(subtype: "credit_card")
|
||||
)
|
||||
AccountProvider.create!(account: sure_account, provider: old_sfa)
|
||||
|
||||
new_sfa = item.simplefin_accounts.create!(
|
||||
name: "Citi Double Cash Card-NEW (1111)",
|
||||
account_id: "seed_citi_new_#{SecureRandom.hex(3)}",
|
||||
currency: "USD",
|
||||
account_type: "credit",
|
||||
current_balance: -987.65,
|
||||
org_data: { "name" => "Citibank" },
|
||||
raw_transactions_payload: [
|
||||
{ "id" => "seed_new_tx_1", "transacted_at" => 1.day.ago.to_i, "posted" => 1.day.ago.to_i, "amount" => "-24.50", "payee" => "Lunch" },
|
||||
{ "id" => "seed_new_tx_2", "transacted_at" => 3.days.ago.to_i, "posted" => 3.days.ago.to_i, "amount" => "-120.00", "payee" => "Gas Station" }
|
||||
]
|
||||
)
|
||||
|
||||
# Simulate a recent sync so the prompt path fires (sync_stats holds the suggestion).
|
||||
suggestions = SimplefinItem::ReplacementDetector.new(item).call
|
||||
sync = item.syncs.create!(
|
||||
status: :completed,
|
||||
sync_stats: { "replacement_suggestions" => suggestions }
|
||||
)
|
||||
sync.update_column(:created_at, Time.current)
|
||||
|
||||
puts "Created:"
|
||||
puts " SimplefinItem: #{item.id}"
|
||||
puts " Dormant sfa (OLD): #{old_sfa.id}"
|
||||
puts " Active sfa (NEW): #{new_sfa.id}"
|
||||
puts " Sure account: #{sure_account.id}"
|
||||
puts " Suggestions: #{suggestions.size}"
|
||||
puts
|
||||
puts "Next: load the accounts page in the dev server. You should see a"
|
||||
puts "replacement prompt on the 'Dev Fraud Scenario' SimpleFIN card."
|
||||
puts
|
||||
puts "To tear down: bin/rails 'simplefin:cleanup_fraud_scenario[#{email}]'"
|
||||
end
|
||||
|
||||
desc "Remove all seeded fraud scenarios for the given user"
|
||||
task :cleanup_fraud_scenario, [ :user_email ] => :environment do |_t, args|
|
||||
if Rails.env.production?
|
||||
abort("Refusing to run simplefin:cleanup_fraud_scenario in production")
|
||||
end
|
||||
email = args[:user_email].presence ||
|
||||
ENV["USER_EMAIL"].presence ||
|
||||
abort("Usage: bin/rails 'simplefin:cleanup_fraud_scenario[user@example.com]'")
|
||||
|
||||
user = User.find_by!(email: email)
|
||||
family = user.family
|
||||
# Drop seeded sfas by account_id prefix (see seed_* values in the seed task)
|
||||
# plus the Sure account created by the seed. This handles both the
|
||||
# standalone-item path and the piggyback-on-existing-item path.
|
||||
seed_sfas = SimplefinAccount
|
||||
.joins(:simplefin_item)
|
||||
.where(simplefin_items: { family_id: family.id })
|
||||
.where("account_id LIKE ?", "seed_citi_%")
|
||||
count_sfas = seed_sfas.count
|
||||
seed_sfas.find_each do |sfa|
|
||||
acct = sfa.current_account
|
||||
AccountProvider.where(provider: sfa).destroy_all
|
||||
acct&.destroy_later if acct&.may_mark_for_deletion?
|
||||
sfa.destroy
|
||||
end
|
||||
# Drop the seeded Sure account even when unlinked (name-based, safe).
|
||||
family.accounts.where(name: "Citi Double Cash (dev seed)").find_each do |acct|
|
||||
acct.destroy_later if acct.may_mark_for_deletion?
|
||||
end
|
||||
# Drop the standalone fallback item if it has no other sfas.
|
||||
family.simplefin_items.where(name: "Dev Fraud Scenario").find_each do |item|
|
||||
item.destroy if item.simplefin_accounts.reload.empty?
|
||||
end
|
||||
puts "Removed #{count_sfas} seeded sfa(s) for #{user.email}"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user