fix(lunchflow): prune orphaned accounts deleted upstream (#1861) (#1886)

* fix(lunchflow): prune orphaned accounts deleted upstream (#1861)

Accounts deleted in Lunch Flow lingered in Sure as unlinked
LunchflowAccount records, permanently pinning the item to "Need setup".
The importer created/updated accounts returned by the API but never
removed records for accounts that disappeared upstream.

Add prune_orphaned_lunchflow_accounts, mirroring SimpleFin's
prune_orphaned_simplefin_accounts: after importing, delete LunchflowAccount
records whose account_id is no longer returned upstream and that are not
linked to an Account via AccountProvider. Linked accounts are kept so the
prune never cascade-destroys a user's Account. The prune is guarded to a
non-empty upstream list so a transient empty/failed response can't wipe out
all accounts.

Fixes #1861

* fix(lunchflow): prune NULL-id orphans + consistent import return shape (#1886)

* fix(lunchflow): make orphan prune resilient to destroy failures

Wrap the per-record destroy in begin/rescue so a single failed prune
doesn't abort the whole import. Pruning runs before transaction fetch,
so an unhandled error would have blocked transaction syncing entirely.
Matches the importer's existing continue-on-error convention.

* fix(lunchflow): only count pruned accounts when destroy succeeds

destroy returns false (without raising) when a before_destroy callback
halts deletion; the prior code incremented the pruned count regardless,
which could inflate it. Increment only on success and log when halted.
This commit is contained in:
Lobster
2026-07-17 16:49:31 -04:00
committed by GitHub
parent 5b9fd1e1a0
commit 8b4bb64a23
2 changed files with 187 additions and 2 deletions

View File

@@ -0,0 +1,121 @@
require "test_helper"
class LunchflowItem::ImporterOrphanPruneTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = LunchflowItem.create!(
family: @family,
name: "Test Lunchflow",
api_key: "test_key_123",
status: :good
)
@importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock())
end
test "prunes orphaned unlinked LunchflowAccount no longer returned upstream" do
orphan = @item.lunchflow_accounts.create!(
account_id: "acct-old",
name: "Deleted Account",
currency: "USD"
)
pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-new" ])
assert_equal 1, pruned
assert_nil LunchflowAccount.find_by(id: orphan.id), "orphaned unlinked account should be deleted"
end
test "keeps unlinked LunchflowAccount that is still returned upstream" do
still_present = @item.lunchflow_accounts.create!(
account_id: "acct-keep",
name: "Active Account",
currency: "USD"
)
pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-keep" ])
assert_equal 0, pruned
assert_not_nil LunchflowAccount.find_by(id: still_present.id)
end
test "does not prune a LunchflowAccount linked via AccountProvider" do
linked = @item.lunchflow_accounts.create!(
account_id: "acct-linked",
name: "Linked Account",
currency: "USD"
)
account = @family.accounts.create!(
name: "Linked Checking",
balance: 100,
currency: "USD",
accountable: Depository.new(subtype: "checking")
)
AccountProvider.create!(account: account, provider: linked)
# Even though it's gone upstream, a linked account must be kept (deleting it
# would cascade-destroy the AccountProvider and orphan the user's Account).
pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-other" ])
assert_equal 0, pruned
assert_not_nil LunchflowAccount.find_by(id: linked.id), "linked account should not be deleted"
end
test "blank upstream list is a no-op so transient failures cannot wipe accounts" do
orphan = @item.lunchflow_accounts.create!(
account_id: "acct-old",
name: "Account",
currency: "USD"
)
assert_equal 0, @importer.send(:prune_orphaned_lunchflow_accounts, [])
assert_not_nil LunchflowAccount.find_by(id: orphan.id), "must not prune when upstream list is empty"
end
test "prunes multiple orphaned unlinked accounts" do
orphan1 = @item.lunchflow_accounts.create!(account_id: "old-1", name: "One", currency: "USD")
orphan2 = @item.lunchflow_accounts.create!(account_id: "old-2", name: "Two", currency: "USD")
kept = @item.lunchflow_accounts.create!(account_id: "new-1", name: "Three", currency: "USD")
pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "new-1" ])
assert_equal 2, pruned
assert_nil LunchflowAccount.find_by(id: orphan1.id)
assert_nil LunchflowAccount.find_by(id: orphan2.id)
assert_not_nil LunchflowAccount.find_by(id: kept.id)
end
test "prunes an unlinked LunchflowAccount with a nil account_id" do
# account_id is nullable; a NULL id can never match upstream and would be
# silently skipped by a plain `where.not(account_id: ...)` (SQL three-valued
# logic). Such an unlinked record is a genuine orphan and must be pruned.
orphan = @item.lunchflow_accounts.create!(
account_id: nil,
name: "Never received an upstream id",
currency: "USD"
)
pruned = @importer.send(:prune_orphaned_lunchflow_accounts, [ "acct-new" ])
assert_equal 1, pruned
assert_nil LunchflowAccount.find_by(id: orphan.id), "nil account_id orphan should be pruned"
end
test "import returns accounts_pruned in its result and prunes orphans end-to-end" do
orphan = @item.lunchflow_accounts.create!(
account_id: "acct-old",
name: "Deleted Account",
currency: "USD"
)
provider = mock()
provider.stubs(:get_accounts).returns(
accounts: [ { id: "acct-new", name: "New Account", currency: "USD" } ]
)
importer = LunchflowItem::Importer.new(@item, lunchflow_provider: provider)
result = importer.import
assert_equal 1, result[:accounts_pruned]
assert_nil LunchflowAccount.find_by(id: orphan.id), "orphan should be pruned through import"
end
end