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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
2fd58c4125
commit
21c63b8b12
@@ -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
|
||||
|
||||
@@ -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) ─────────────────────
|
||||
|
||||
Reference in New Issue
Block a user