fix(enable-banking): reconnect supersedes the old connection and stops duplicate imports (#1728)
* fix(enable-banking): supersede the old connection on bank reconnect and stop renewal duplicates
A renewal performed via the bank list ("Anslut ny bank") created a second
bank_connections row and left the old one parked in 'expired' forever: an
eternal "Åtgärd krävs" card, a red status chip, transactions stranded on the
dead row (so the picker's gap-fill probe read the renewal as a first
connect), and re-imported history for no-IBAN accounts whose provider uids
change on re-authorization.
- New migration: additive superseded_by uuid (FK, ON DELETE SET NULL) +
superseded_at + partial index on bank_connections. Status 'revoked' is
reused for superseded rows (no CHECK change); superseded_by disambiguates
a supersede from a user disconnect. File only: not applied anywhere yet.
- New lib/supersede.ts: after the OAuth callback finalizes, park same-bank
siblings matched by IBAN overlap (an ACTIVE sibling without overlap is
never touched; no-IBAN fallback only for dead siblings when neither side
has IBANs), revoke their EB session only when countLiveSiblings says
nobody shares it, re-point their transactions in id batches, demote
leftover cash_accounts claims (the mirror then promotes them by IBAN),
carry last_synced_at + initial_sync_* onto the survivor, and emit the new
bank_connection.superseded audit event.
- /connect fresh path: 409 { code: 'EXISTING_CONNECTION',
existing_connection_id } when a non-revoked same-bank row exists, unless
the body carries force_new: true (escape hatch for a second login at the
same bank). Runs after the zombie sweep; reconnect-in-place unaffected.
- Dedup scope stability: StoredAccount.dedup_scope pins the external_id
account scope at first ingest (normalized IBAN, else the uid of that
moment), is carried across in-place reconnects and supersedes by IBAN
match, and sync.ts uses dedup_scope ?? IBAN ?? uid (stamping legacy rows
lazily). The external_id FORMAT is untouched.
- AccountPickerDialog gap-fill probe also includes superseded connection
ids so the renewal default never races the transaction re-point.
- Sync toast (BankSyncNowButton) now also reports skipped duplicates
(sv+en strings) so a correctly deduped renewal does not look broken.
Tests: supersede unit tests, /connect 409 + force_new, callback supersede
wiring + dedup-scope carry, sync external_id stability across uid changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(enable-banking): scope the connect 409 to dead siblings and harden supersede ordering
- POST /connect only 409s when the same-bank sibling is expired/error/
pending_selection: an active row (a second legitimate login at the same
bank) never blocks a fresh connect; force_new bypass kept. The 409 text
now names the bank and points at Fornya samtycke.
- supersede parks the sibling row BEFORE revoking its EB session, and skips
the revoke entirely (logged) when the park update fails, so a failed park
can no longer leave a live-looking row with a dead session.
- callback keeps a survivor account's explicit dedup_scope instead of
letting a carried sibling scope clobber it; carried scopes only apply
when the survivor's scope was derived (IBAN/uid fallback).
- sync-now toast joins its two sentences with '. ' so the imported and
skipped-duplicates messages no longer run together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
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
Claude Fable 5
Jakob Wennberg
parent
1ded1af8fe
commit
3de5dee553
@@ -10,8 +10,10 @@ import {
|
||||
upsertFromPsd2,
|
||||
resolvePsd2LedgerAccount,
|
||||
defaultLedgerForCurrency,
|
||||
normalizeIban,
|
||||
} from '@/lib/cash-accounts/service'
|
||||
import { fanOutSessionRenewal } from '@/extensions/general/enable-banking/lib/session-sharing'
|
||||
import { supersedeSiblingConnections } from '@/extensions/general/enable-banking/lib/supersede'
|
||||
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
|
||||
|
||||
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
||||
@@ -41,6 +43,13 @@ interface PendingConnection {
|
||||
* lib/session-sharing.ts). Null on a first-time connect.
|
||||
*/
|
||||
session_id: string | null
|
||||
/**
|
||||
* The accounts the row held BEFORE this callback overwrites them, so the
|
||||
* dedup scope each account was first ingested under survives an in-place
|
||||
* reconnect (several ASPSPs mint new uids on re-authorization).
|
||||
* Null on a first-time connect.
|
||||
*/
|
||||
accounts_data: StoredAccount[] | null
|
||||
}
|
||||
|
||||
// Shown in the settings banner when the session exchange/finalize fails.
|
||||
@@ -180,7 +189,7 @@ export async function GET(request: Request) {
|
||||
// state stays a plain redirect.
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, company_id, bank_name, status, session_id')
|
||||
.select('id, user_id, company_id, bank_name, status, session_id, accounts_data')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
@@ -307,16 +316,48 @@ async function finalizeConnection(
|
||||
// them here. The first sync (after the user enables specific accounts)
|
||||
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
|
||||
// user deselects never have their balance pulled.
|
||||
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
|
||||
uid: account.uid,
|
||||
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,
|
||||
}))
|
||||
// Dedup scopes the row's accounts were first ingested under. The scope of a
|
||||
// legacy account without an explicit dedup_scope is what lib/sync.ts derived
|
||||
// for it historically: the normalized IBAN, else its (then-current) uid.
|
||||
// Matching by IBAN first covers the ASPSPs that mint new uids on every
|
||||
// re-authorization; the uid match covers no-IBAN accounts whose uid is
|
||||
// stable. A no-IBAN account whose uid changed cannot be matched here: it
|
||||
// gets a fresh scope, same as before this field existed.
|
||||
const priorAccounts = pendingConnection.accounts_data ?? []
|
||||
// explicit: the prior account carried a stored dedup_scope (as opposed to
|
||||
// one derived here from its IBAN/uid). The supersede pass below only lets a
|
||||
// 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 }>()
|
||||
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 accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => {
|
||||
const normalizedIban = normalizeIban(account.account_id?.iban)
|
||||
return {
|
||||
uid: account.uid,
|
||||
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,
|
||||
// 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).
|
||||
dedup_scope:
|
||||
(normalizedIban ? priorScopeByIban.get(normalizedIban)?.scope : undefined) ??
|
||||
priorScopeByUid.get(account.uid)?.scope ??
|
||||
normalizedIban ??
|
||||
account.uid,
|
||||
}
|
||||
})
|
||||
|
||||
// Stay in 'pending_selection' until the user confirms which accounts to sync.
|
||||
// The cron and manual sync routes both skip this status, so no transactions
|
||||
@@ -371,6 +412,55 @@ async function finalizeConnection(
|
||||
}
|
||||
}
|
||||
|
||||
// A successful (re)connect supersedes any older row for the same bank in
|
||||
// this company. Without this, a renewal performed via the bank list left
|
||||
// the old row parked in 'expired' ("Åtgärd krävs" forever, red chip) with
|
||||
// the transaction history stranded on it, so the picker treated the renewal
|
||||
// as a first connect and re-imported bookkept periods. Runs BEFORE the
|
||||
// cash_accounts mirror below: the supersede demotes the old row's ledger
|
||||
// claims to manual, and the mirror then promotes them onto this row by
|
||||
// IBAN, exactly like a disconnect-then-reconnect. Non-fatal: this
|
||||
// connection is already renewed and correct.
|
||||
let carriedScopeDirty = false
|
||||
try {
|
||||
const supersedeResult = await supersedeSiblingConnections(supabase, {
|
||||
companyId: updatedConnection.company_id,
|
||||
userId: updatedConnection.user_id,
|
||||
newConnectionId: updatedConnection.id,
|
||||
bankName: updatedConnection.bank_name ?? null,
|
||||
newSessionId: session_id,
|
||||
newAccounts: accountsMetadata,
|
||||
})
|
||||
// Carry the superseded rows' dedup scopes onto this row's accounts so a
|
||||
// renewal keeps minting the same transaction external_ids (see
|
||||
// StoredAccount.dedup_scope). An account whose OWN prior row already
|
||||
// carried an explicit dedup_scope keeps it: that scope is the one its
|
||||
// external_ids were actually minted under, and a sibling's scope for the
|
||||
// same IBAN must not clobber it. Only accounts whose scope was derived
|
||||
// here (IBAN/uid fallback) take the carried one. Persisted by the
|
||||
// accounts_data write below.
|
||||
if (supersedeResult.dedupScopeByIban.size > 0) {
|
||||
for (const account of accountsMetadata) {
|
||||
const normalizedIban = normalizeIban(account.iban)
|
||||
const carried = normalizedIban
|
||||
? supersedeResult.dedupScopeByIban.get(normalizedIban)
|
||||
: undefined
|
||||
const survivorExplicit =
|
||||
(normalizedIban ? priorScopeByIban.get(normalizedIban)?.explicit : undefined) ??
|
||||
priorScopeByUid.get(account.uid)?.explicit ??
|
||||
false
|
||||
if (carried && !survivorExplicit && account.dedup_scope !== carried) {
|
||||
account.dedup_scope = carried
|
||||
carriedScopeDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (supersedeError) {
|
||||
log.error('supersede pass failed', supersedeError as Error, {
|
||||
connectionId: updatedConnection.id,
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read
|
||||
// from the canonical entity table. Accounts already mirrored under the same
|
||||
// (connection, uid) keep their ledger_account — re-deriving it here would
|
||||
@@ -390,7 +480,7 @@ async function finalizeConnection(
|
||||
),
|
||||
)
|
||||
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
|
||||
let accountsDataDirty = false
|
||||
let accountsDataDirty = carriedScopeDirty
|
||||
|
||||
for (const account of accountsMetadata) {
|
||||
let targetLedger = existingLedgerByUid.get(account.uid)
|
||||
|
||||
Reference in New Issue
Block a user