Fix: SimpleFIN account re-link duplication (#554)

* Add orphan pruning tests for Simplefin importer and implement pruning logic

- Introduced `SimplefinItem::ImporterOrphanPruneTest` to verify orphaned `SimplefinAccount` pruning scenarios.
- Added logic in `SimplefinItem::Importer` to remove orphaned `SimplefinAccounts` when upstream account IDs change.
- Ensured linked accounts via legacy FK or `AccountProvider` are preserved during pruning.
- Updated sync stats to track pruned accounts.

* Optimize SimplefinAccount query in importer to prevent N+1 issues

- Added eager-loading of `account` and `account_provider` associations when retrieving orphaned `SimplefinAccounts`.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
This commit is contained in:
LPW
2026-01-07 07:58:47 -05:00
committed by GitHub
parent 3b1495422a
commit 42b94947bf
2 changed files with 213 additions and 0 deletions

View File

@@ -402,9 +402,48 @@ class SimplefinItem::Importer
persist_stats!
end
end
# Clean up orphaned SimplefinAccount records whose account_id no longer exists upstream.
# This handles the case where a user deletes and re-adds an institution in SimpleFIN,
# which generates new account IDs. Without this cleanup, both old (stale) and new
# SimplefinAccount records would appear in the setup UI as duplicates.
upstream_account_ids = discovery_data[:accounts].map { |a| a[:id].to_s }.compact
prune_orphaned_simplefin_accounts(upstream_account_ids)
end
end
# Removes SimplefinAccount records that no longer exist upstream and are not linked to any Account.
# This prevents duplicate accounts from appearing in the setup UI after a user re-adds an
# institution in SimpleFIN (which generates new account IDs).
def prune_orphaned_simplefin_accounts(upstream_account_ids)
return if upstream_account_ids.blank?
# Find SimplefinAccount records with account_ids NOT in the upstream set
# Eager-load associations to prevent N+1 queries when checking linkage
orphaned = simplefin_item.simplefin_accounts
.includes(:account, :account_provider)
.where.not(account_id: upstream_account_ids)
.where.not(account_id: nil)
orphaned.each do |sfa|
# Only delete if not linked to any Account (via legacy FK or AccountProvider)
# Note: sfa.account checks the legacy FK on Account.simplefin_account_id
# sfa.account_provider checks the new AccountProvider join table
linked_via_legacy = sfa.account.present?
linked_via_provider = sfa.account_provider.present?
if !linked_via_legacy && !linked_via_provider
Rails.logger.info "SimpleFin: Pruning orphaned SimplefinAccount id=#{sfa.id} account_id=#{sfa.account_id} (no longer exists upstream)"
stats["accounts_pruned"] = stats.fetch("accounts_pruned", 0) + 1
sfa.destroy
else
Rails.logger.info "SimpleFin: Keeping stale SimplefinAccount id=#{sfa.id} account_id=#{sfa.account_id} (still linked to Account)"
end
end
persist_stats! if stats["accounts_pruned"].to_i > 0
end
# Fetches accounts (and optionally transactions/holdings) from SimpleFin.
#
# Params: