fix(bank): renewal reuses IBAN-matched ledgers and keeps deselected accounts deselected (#1805)

* fix(bank): renewal reuses IBAN-matched ledgers and keeps deselected accounts deselected

Two Enable Banking renewal defects reported from a SEB connection:

1. Dead 19xx accounts per renewal. The callback pre-seeded the resolver's
   exclude set with every ledger the connection already mirrored. SEB mints
   new account uids on re-auth, so no uid matched, the IBAN hit on the old
   row was rejected by its own ledger being excluded, and a fresh 195x slot
   was allocated (and created in the chart) on every renewal. Only ledgers
   still claimed by a uid present in the new session are excluded now; the
   stale row is promoted via the IBAN match as intended. Stale ledgers stay
   safe from the allocator, which already skips every cash_accounts ledger.

2. Deselected accounts came back pre-checked. accounts_data was rebuilt with
   enabled:true unconditionally, so a private card set to "Synkas ej" was
   re-enabled and the mirror flipped cash_accounts.enabled back. The prior
   flag is now carried over by IBAN, then uid; only genuinely new accounts
   default to enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bank): prefer exact uid over IBAN when carrying the sync-enabled flag

Skeptic refutation: one session can list the same IBAN twice (one resource
per balance type). IBAN-first lookup made the first prior entry win for
both, so a deselected duplicate could re-enable, or the live account could
come back deselected and silently stop syncing. Exact uid identity now wins;
IBAN is the fallback for ASPSPs that mint new uids on re-auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-23 00:36:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e53b478833
commit 2d22039461
2 changed files with 216 additions and 5 deletions
@@ -329,12 +329,23 @@ async function finalizeConnection(
// carried sibling scope onto an account whose own scope is NOT explicit.
const priorScopeByIban = new Map<string, { scope: string; explicit: boolean }>()
const priorScopeByUid = new Map<string, { scope: string; explicit: boolean }>()
// The user's earlier sync choice per account ("Synkas ej" = enabled:false)
// must survive a renewal: a deselected private card that comes back
// pre-checked lands its transactions in the company's books the moment the
// user saves the picker with defaults. Matched by uid first (exact resource
// identity; one session can list the same IBAN twice, e.g. one resource per
// balance type), then by IBAN for ASPSPs that mint new uids on re-auth.
const priorEnabledByIban = new Map<string, boolean>()
const priorEnabledByUid = new Map<string, boolean>()
for (const prior of priorAccounts) {
const priorIban = normalizeIban(prior.iban)
const priorScope = prior.dedup_scope || priorIban || prior.uid
const priorEntry = { scope: priorScope, explicit: Boolean(prior.dedup_scope) }
if (priorIban && !priorScopeByIban.has(priorIban)) priorScopeByIban.set(priorIban, priorEntry)
if (!priorScopeByUid.has(prior.uid)) priorScopeByUid.set(prior.uid, priorEntry)
const priorEnabled = prior.enabled !== false
if (priorIban && !priorEnabledByIban.has(priorIban)) priorEnabledByIban.set(priorIban, priorEnabled)
if (!priorEnabledByUid.has(prior.uid)) priorEnabledByUid.set(prior.uid, priorEnabled)
}
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => {
@@ -344,10 +355,14 @@ async function finalizeConnection(
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
// Default to enabled. The user is presented with a picker
// immediately after this callback to uncheck unwanted accounts
// before any transactions are fetched.
enabled: true,
// Carry the user's earlier choice for an account we have seen before;
// only genuinely new accounts default to enabled. The picker shown
// right after this callback pre-checks from this flag, and no
// transactions are fetched before the user saves it.
enabled:
priorEnabledByUid.get(account.uid) ??
(normalizedIban ? priorEnabledByIban.get(normalizedIban) : undefined) ??
true,
// Pin the external_id account scope at first ingest so it survives
// re-authorizations. Byte-identical to the derivation lib/sync.ts
// applied before this field existed (normalized IBAN, else uid).
@@ -479,7 +494,20 @@ async function finalizeConnection(
(r) => [r.external_uid, r.ledger_account],
),
)
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
// Only ledgers still claimed by a uid the bank returned in THIS session
// block the resolver. A row whose uid the ASPSP retired on re-auth (SEB
// mints new uids on every renewal) is exactly the row the IBAN match must
// promote; seeding its ledger into the exclude set made the resolver reject
// its own IBAN hit and allocate a fresh 19xx slot per renewal, so the chart
// grew a dead sub-account each time. Stale ledgers are still safe from the
// allocator: findFreeLedgerAccount skips every ledger a cash_accounts row
// holds, whatever its uid.
const sessionUids = new Set(accountsMetadata.map((a) => a.uid))
const assignedLedgers = new Set<string>(
[...existingLedgerByUid.entries()]
.filter(([uid]) => sessionUids.has(uid))
.map(([, ledger]) => ledger),
)
let accountsDataDirty = carriedScopeDirty
for (const account of accountsMetadata) {