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
+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) ─────────────────────