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
@@ -9,13 +9,21 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({
|
||||
}))
|
||||
|
||||
// Use hoisted to safely create mock objects referenced in vi.mock factories
|
||||
const { mockFrom, mockUpsertFromPsd2, mockAllocate } = vi.hoisted(() => {
|
||||
const { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede } = vi.hoisted(() => {
|
||||
const mockFrom = vi.fn()
|
||||
const mockUpsertFromPsd2 = vi.fn()
|
||||
const mockAllocate = vi.fn()
|
||||
return { mockFrom, mockUpsertFromPsd2, mockAllocate }
|
||||
const mockSupersede = vi.fn()
|
||||
return { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede }
|
||||
})
|
||||
|
||||
// The supersede pass has its own unit tests (extensions/general/enable-banking/
|
||||
// __tests__/supersede.test.ts); here it is mocked so these tests assert the
|
||||
// callback WIRES it correctly without scripting its internal queries.
|
||||
vi.mock('@/extensions/general/enable-banking/lib/supersede', () => ({
|
||||
supersedeSiblingConnections: (...args: unknown[]) => mockSupersede(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn().mockResolvedValue({
|
||||
from: mockFrom,
|
||||
@@ -45,6 +53,13 @@ vi.mock('@/lib/cash-accounts/service', () => ({
|
||||
},
|
||||
defaultLedgerForCurrency: (currency: string) =>
|
||||
CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930',
|
||||
// Real (trivial) implementation: the route normalizes IBANs when stamping
|
||||
// dedup scopes onto accounts_data.
|
||||
normalizeIban: (iban?: string | null) => {
|
||||
if (!iban) return null
|
||||
const normalized = iban.replace(/\s+/g, '').toUpperCase()
|
||||
return normalized || null
|
||||
},
|
||||
}))
|
||||
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
|
||||
@@ -74,6 +89,7 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpsertFromPsd2.mockResolvedValue(undefined)
|
||||
mockSupersede.mockResolvedValue({ supersededIds: [], dedupScopeByIban: new Map() })
|
||||
// Allocator stand-in mirroring the real behavior: currency default first,
|
||||
// then the next free 1931–1959 slot (skipping other currency defaults).
|
||||
mockAllocate.mockImplementation(
|
||||
@@ -326,6 +342,248 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
expect(mirrored.external_uid).toBe('acc-new')
|
||||
})
|
||||
|
||||
it('runs the same-bank supersede pass with the new session, accounts, and connection identity', async () => {
|
||||
// Fresh connect while an EXPIRED sibling to the same bank exists: the
|
||||
// supersede pass (unit-tested separately) must be handed everything it
|
||||
// needs to park the sibling and re-point its transactions.
|
||||
let callIndex = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
return mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.update = vi.fn(() => chain)
|
||||
chain.eq = vi.fn().mockReturnValue(chain)
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.single = vi.fn().mockResolvedValue({
|
||||
data: { id: 'conn-1', bank_name: 'TestBank', company_id: 'company-1', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-1',
|
||||
accounts: [
|
||||
{ uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2024-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'TestBank', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
expect(response.status).toBe(200)
|
||||
// Reading the body drives the stream, which awaits the finalize work.
|
||||
await response.text()
|
||||
|
||||
expect(mockSupersede).toHaveBeenCalledTimes(1)
|
||||
const [, input] = mockSupersede.mock.calls[0] as [unknown, {
|
||||
companyId: string
|
||||
userId: string
|
||||
newConnectionId: string
|
||||
bankName: string | null
|
||||
newSessionId: string | null
|
||||
newAccounts: Array<{ uid: string; dedup_scope?: string }>
|
||||
}]
|
||||
expect(input.companyId).toBe('company-1')
|
||||
expect(input.userId).toBe('user-1')
|
||||
expect(input.newConnectionId).toBe('conn-1')
|
||||
expect(input.bankName).toBe('TestBank')
|
||||
expect(input.newSessionId).toBe('sess-1')
|
||||
expect(input.newAccounts).toHaveLength(1)
|
||||
// First-connect accounts get their dedup scope pinned to the normalized
|
||||
// IBAN (byte-identical to what lib/sync.ts derives).
|
||||
expect(input.newAccounts[0].dedup_scope).toBe('SE1234')
|
||||
})
|
||||
|
||||
it('applies dedup scopes carried from superseded siblings to accounts_data', async () => {
|
||||
mockSupersede.mockResolvedValue({
|
||||
supersededIds: ['old-1'],
|
||||
// The sibling's account was first ingested under its old provider uid.
|
||||
dedupScopeByIban: new Map([['SE1234', 'legacy-uid']]),
|
||||
})
|
||||
|
||||
const capturedUpdates: Record<string, unknown>[] = []
|
||||
let callIndex = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
return mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.update = vi.fn((payload: Record<string, unknown>) => {
|
||||
capturedUpdates.push(payload)
|
||||
return chain
|
||||
})
|
||||
chain.eq = vi.fn().mockReturnValue(chain)
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.single = vi.fn().mockResolvedValue({
|
||||
data: { id: 'conn-1', bank_name: 'TestBank', company_id: 'company-1', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-1',
|
||||
accounts: [
|
||||
{ uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2024-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'TestBank', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
expect(response.status).toBe(200)
|
||||
await response.text()
|
||||
|
||||
// The follow-up accounts_data write persists the carried scope so the
|
||||
// renewal keeps minting the sibling's external_ids.
|
||||
const followUp = capturedUpdates[capturedUpdates.length - 1]
|
||||
const persisted = followUp.accounts_data as Array<{ uid: string; dedup_scope?: string }>
|
||||
expect(persisted.find((a) => a.uid === 'acc-1')?.dedup_scope).toBe('legacy-uid')
|
||||
})
|
||||
|
||||
it('carries the prior accounts_data dedup scope across an in-place reconnect', async () => {
|
||||
const capturedUpdates: Record<string, unknown>[] = []
|
||||
let callIndex = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
// The established row being reconnected in place: its account was
|
||||
// first ingested under the uid the ASPSP has since replaced.
|
||||
return mockChain({
|
||||
data: {
|
||||
id: 'conn-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
bank_name: 'TestBank',
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
accounts_data: [
|
||||
{ uid: 'uid-old', iban: 'SE1234', currency: 'SEK', dedup_scope: 'uid-first' },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.update = vi.fn((payload: Record<string, unknown>) => {
|
||||
capturedUpdates.push(payload)
|
||||
return chain
|
||||
})
|
||||
chain.eq = vi.fn().mockReturnValue(chain)
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.single = vi.fn().mockResolvedValue({
|
||||
data: { id: 'conn-1', bank_name: 'TestBank', company_id: 'company-1', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [
|
||||
// Same IBAN, freshly minted uid.
|
||||
{ uid: 'uid-new', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2024-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'TestBank', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
expect(response.status).toBe(200)
|
||||
await response.text()
|
||||
|
||||
const connectionWrite = capturedUpdates[0]
|
||||
const accountsData = connectionWrite.accounts_data as Array<{
|
||||
uid: string
|
||||
dedup_scope?: string
|
||||
}>
|
||||
expect(accountsData).toHaveLength(1)
|
||||
expect(accountsData[0].uid).toBe('uid-new')
|
||||
// The scope pinned at first ingest survives the uid change.
|
||||
expect(accountsData[0].dedup_scope).toBe('uid-first')
|
||||
})
|
||||
|
||||
it('prefers the survivor account explicit dedup scope over a carried sibling scope', async () => {
|
||||
// A superseded sibling shares the IBAN but was ingested under a different
|
||||
// scope. The survivor's own row already pinned an explicit scope for this
|
||||
// account: that is what its external_ids were minted under, so the
|
||||
// sibling's scope must NOT clobber it.
|
||||
mockSupersede.mockResolvedValue({
|
||||
supersededIds: ['old-1'],
|
||||
dedupScopeByIban: new Map([['SE1234', 'sibling-scope']]),
|
||||
})
|
||||
|
||||
const capturedUpdates: Record<string, unknown>[] = []
|
||||
let callIndex = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
return mockChain({
|
||||
data: {
|
||||
id: 'conn-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
bank_name: 'TestBank',
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
accounts_data: [
|
||||
{ uid: 'uid-old', iban: 'SE1234', currency: 'SEK', dedup_scope: 'survivor-scope' },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.update = vi.fn((payload: Record<string, unknown>) => {
|
||||
capturedUpdates.push(payload)
|
||||
return chain
|
||||
})
|
||||
chain.eq = vi.fn().mockReturnValue(chain)
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.single = vi.fn().mockResolvedValue({
|
||||
data: { id: 'conn-1', bank_name: 'TestBank', company_id: 'company-1', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [
|
||||
{ uid: 'uid-new', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2024-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'TestBank', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
expect(response.status).toBe(200)
|
||||
await response.text()
|
||||
|
||||
// Every accounts_data write keeps the survivor's explicit scope: neither
|
||||
// the connection write nor any carried-scope follow-up flips it.
|
||||
const accountsWrites = capturedUpdates.filter((u) => Array.isArray(u.accounts_data))
|
||||
expect(accountsWrites.length).toBeGreaterThan(0)
|
||||
for (const write of accountsWrites) {
|
||||
const accountsData = write.accounts_data as Array<{ uid: string; dedup_scope?: string }>
|
||||
expect(accountsData.find((a) => a.uid === 'uid-new')?.dedup_scope).toBe('survivor-scope')
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes the fresh row and streams an error redirect when the session exchange fails', async () => {
|
||||
const deleteCalls: unknown[] = []
|
||||
const updateCalls: unknown[] = []
|
||||
|
||||
@@ -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