fix(enable-banking): carry dedup scope and cash account across a no-IBAN uid change on reconnect (#1826)

Root cause (issue #1709 residual corner): on an in-place reconnect, the
callback carries each account's external_id dedup scope by matching prior
accounts by IBAN or uid. A no-IBAN account whose ASPSP minted a new uid
matched neither, so it got a fresh scope: every historical external_id
regenerated, Layer-1 dedup missed the re-import, and the whole history
came back as new unbooked rows. The cash_accounts mirror then could not
find the old row either and allocated an overflow 19xx slot plus a NEW
row, which also blocked the content-dedup bridge's account guard.

Fix: pair such accounts by elimination, only when unambiguous (per
currency, exactly one unclaimed prior and exactly one fresh-scope new
account, neither with an IBAN): carry the prior scope and enabled flag,
and reuse the connection's own old cash_accounts row via the existing
explicit reuse_cash_account_id promote path in upsertFromPsd2, so the
row id, ledger, and transaction links survive the uid change. Any
ambiguity keeps the previous fresh-scope behavior. Also count
account-incompatible same-feed orphaned ids in the scope-drift shadow
(log-only) so fleet validation can see this incident class.

IBAN-carrying accounts were already fixed by #1705/#1728; the frozen
external_id format is untouched.


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 13:11:13 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 2fd58c4125
commit 21c63b8b12
5 changed files with 374 additions and 8 deletions
@@ -684,6 +684,34 @@ describe('upsertFromPsd2', () => {
expect(stub.upserts).toHaveLength(1)
})
it('promotes a SAME-connection holder under a retired uid when explicitly named via reuse_cash_account_id', async () => {
// Issue #1709: in-place reconnect of a no-IBAN account whose ASPSP minted
// a new uid. The callback pairs the account with the connection's own old
// row and names it explicitly; the promote re-keys that row to the new uid
// in place, so its id (and the transactions linked to it) survive. Without
// the explicit name the plain upsert would INSERT and trip the
// (company_id, ledger_account) UNIQUE constraint; the previous test pins
// that an UNNAMED same-connection holder still routes through the plain
// upsert (the general active-holder rejection is not loosened).
const stub = makeUpsertStub({
holder: { id: 'row-self', bank_connection_id: 'conn-new' },
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', {
...UPSERT_INPUT,
external_uid: 'uid-2',
reuse_cash_account_id: 'row-self',
})
expect(stub.upserts).toHaveLength(0)
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-self')
expect(stub.updates[0].payload).toMatchObject({
bank_connection_id: 'conn-new',
external_uid: 'uid-2',
ledger_account: '1930',
})
})
it('deletes an empty duplicate row for the same connection+uid before promoting', async () => {
// Stuck-user recovery: the reconnect callback mirrored uid-1 onto 1939
// while 1930 was wrongly blocked. On remap to 1930 the empty 1939
+19
View File
@@ -524,6 +524,14 @@ export async function ingestTransactions(
const driftCandidateStoredByBucket = new Map<string, number>()
if (batchIsImportFeed && scopeDriftShadow) {
// Same-feed orphaned-id rows on an INcompatible account are excluded from
// the candidates (a genuinely different account on the same company must
// never bridge), but they are exactly what a reconnect that minted a NEW
// cash_account for the same physical account produces (issue #1709): every
// stored twin then sits on the old account and the shadow stays 0 during
// the incident it was built to measure. Count them separately, log-only,
// so fleet validation can see those incidents.
let accountIncompatibleDriftRows = 0
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
for (const [k, entries] of bucket) {
for (const entry of entries) {
@@ -535,10 +543,21 @@ export async function ingestTransactions(
const idOrphaned = entry.externalId !== null && !incomingIdSet.has(entry.externalId)
if (sameFeed && accountCompatible && idOrphaned) {
driftCandidateStoredByBucket.set(k, (driftCandidateStoredByBucket.get(k) ?? 0) + 1)
} else if (sameFeed && idOrphaned) {
accountIncompatibleDriftRows++
}
}
}
}
if (accountIncompatibleDriftRows > 0) {
log.info('import dedup shadow: account-incompatible same-feed orphaned ids', {
decision: 'same-feed-scope-drift-cross-account',
mode: 'shadow',
count: accountIncompatibleDriftRows,
cashAccountId,
batchSource,
})
}
}
// ── Shadow-mode date-drift precompute (measure only) ─────────────────────