From 3de5dee5532364ce9fa6f2321e0cd4cf95873f73 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 10:08:04 +0200 Subject: [PATCH] fix(enable-banking): reconnect supersedes the old connection and stops duplicate imports (#1728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../callback/__tests__/route.test.ts | 262 +++++++++++- .../enable-banking/callback/route.ts | 114 ++++- components/transactions/BankSyncNowButton.tsx | 20 +- .../__tests__/connect-cleanup.test.ts | 157 ++++++- .../__tests__/session-expired.test.ts | 3 + .../__tests__/supersede.test.ts | 338 +++++++++++++++ .../components/AccountPickerDialog.tsx | 20 +- extensions/general/enable-banking/index.ts | 42 +- .../enable-banking/lib/__tests__/sync.test.ts | 82 ++++ .../general/enable-banking/lib/supersede.ts | 399 ++++++++++++++++++ extensions/general/enable-banking/lib/sync.ts | 12 +- extensions/general/enable-banking/types.ts | 7 + lib/events/handlers/event-log-handler.ts | 1 + lib/events/types.ts | 5 + messages/en.json | 2 + messages/sv.json | 2 + ...9175522_bank_connections_superseded_by.sql | 25 ++ 18 files changed, 1469 insertions(+), 23 deletions(-) create mode 100644 extensions/general/enable-banking/__tests__/supersede.test.ts create mode 100644 extensions/general/enable-banking/lib/supersede.ts create mode 100644 supabase/migrations/20260819175522_bank_connections_superseded_by.sql diff --git a/DECISIONS.md b/DECISIONS.md index 3b5086a3..34f42ba9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1091,3 +1091,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] Periodisering auto-detect materiality floor uses entity_type as a K1 proxy: no stored flag distinguishes förenklat årsbokslut (K1, BFNAR 2006:1) from full årsbokslut (BFNAR 2017:3) for enskild firma, so every EF gets K1 wording and every AB gets K2, always advisory ("behöver normalt inte"), never prohibitive. Suggestions under 5 000 kr are tagged low-confidence (unticked) rather than dropped because the relief is a MAY, not a MUST; personnel-cost lines (7xxx) are exempt from the floor since K1/K2 require personnel costs to always be accrued. [2026-08-19] Hem build-assistant hero downgraded to the quiet-sentence pattern (AgentPromo, matches SkatteverketPromoCard; founder direction 2026-08-18 'redesign first, maybe remove later'): dismissal is per-company localStorage (erp_agent_promo_dismissed:) like the SKV promo, gate and hasAi/billing routing unchanged. [2026-08-19] Banking settings UI state derives from a pure helper (extensions/general/enable-banking/lib/connection-state.ts), not inline JSX conditions: sort precedence, the single page-level .attn sentence, and each row's one primary action must agree on which state a connection is in, and only a pure module can unit-test that. The same-bank connect intercept excludes 'pending' rows (an in-flight authorization is not a renewable connection) and the fresh-connect body sends force_new: true after the intercept so the parallel 409 server guard can distinguish deliberate second connections; 'pending' rows now render as a spinner row ("Väntar på banken") for their whole lifetime instead of only locking the connect button for 30 s, since an invisible in-flight row was the confusion. +[2026-08-19] Bank reconnect supersede reuses status 'revoked' plus a new superseded_by column instead of a new status value, and re-points transactions.bank_connection_id to the superseding row: every existing filter, ledger-claim release, and cron skip already handles 'revoked' correctly (no CHECK-constraint migration on a live table), superseded_by disambiguates a supersede from a user disconnect, and re-pointing the feed rows (plain FK metadata, never journal tables) is what makes the picker's gap-fill probe and per-connection scoping survive a renewal. diff --git a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts index 45745431..23934028 100644 --- a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts @@ -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 = {} + 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[] = [] + 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 = {} + chain.update = vi.fn((payload: Record) => { + 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[] = [] + 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 = {} + chain.update = vi.fn((payload: Record) => { + 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[] = [] + 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 = {} + chain.update = vi.fn((payload: Record) => { + 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[] = [] diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index 1825fdef..953e3b20 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -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() + const priorScopeByUid = new Map() + 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(existingLedgerByUid.values()) - let accountsDataDirty = false + let accountsDataDirty = carriedScopeDirty for (const account of accountsMetadata) { let targetLedger = existingLedgerByUid.get(account.uid) diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx index 77d74e0b..fd038172 100644 --- a/components/transactions/BankSyncNowButton.tsx +++ b/components/transactions/BankSyncNowButton.tsx @@ -141,9 +141,23 @@ export function useBankSync() { } toast({ title: t('bank_sync_button_now'), - description: data.imported === 1 - ? t('bank_sync_new_since_last_visit_one') - : t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }), + // Surface skipped duplicates too: a renewal that dedupes correctly + // imports 0 rows, which without this line reads as a broken sync. + // Joined with '. ' so the two sentences do not run together (the + // imported-count strings are shared with the BankSyncSinceLastVisit + // chip and deliberately carry no trailing period). + description: [ + data.imported === 1 + ? t('bank_sync_new_since_last_visit_one') + : t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }), + typeof data.duplicates === 'number' && data.duplicates > 0 + ? data.duplicates === 1 + ? t('bank_sync_duplicates_skipped_one') + : t('bank_sync_duplicates_skipped_many', { count: data.duplicates }) + : null, + ] + .filter(Boolean) + .join('. '), }) // Tell the neighbouring status chip to refetch so it doesn't keep showing // the pre-sync "synced Nd ago" until a hard reload. diff --git a/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts b/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts index cc171d35..8ffccd65 100644 --- a/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts +++ b/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts @@ -41,7 +41,7 @@ interface RecordedChain { function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain { const calls: RecordedCall[] = [] const chain: Record = { _calls: calls } - for (const m of ['select', 'eq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) { + for (const m of ['select', 'eq', 'neq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) { chain[m] = vi.fn((...args: unknown[]) => { calls.push({ method: m, args }) return chain @@ -118,6 +118,9 @@ describe('POST /connect never-activated row cleanup', () => { } else if (call === 2) { // The sweep: returns the deleted never-activated rows. chain = makeChain({ data: [{ id: 'stale-1' }, { id: 'old-error' }] }) + } else if (call === 3) { + // Existing-connection guard: nothing established remains post-sweep. + chain = makeChain({ data: null }) } else { // Insert of the fresh connection row. chain = makeChain({ data: { id: 'new-conn' } }) @@ -186,6 +189,9 @@ describe('POST /connect never-activated row cleanup', () => { chain = makeChain({ data: null }) } else if (call === 2) { chain = makeChain({ data: [{ id: 'old-error' }] }) + } else if (call === 3) { + // Existing-connection guard: the swept zombie is gone, nothing remains. + chain = makeChain({ data: null }) } else { chain = makeChain({ data: { id: 'new-conn' } }) } @@ -211,14 +217,16 @@ describe('POST /connect auth-method pinning wired into startAuthorization', () = }) }) - // Fresh-connect from() sequence: recent-pending check, zombie sweep, insert. - // Explicit psu_type in the body skips the companies entity_type lookup. + // Fresh-connect from() sequence: recent-pending check, zombie sweep, + // existing-connection guard, insert. Explicit psu_type in the body skips + // the companies entity_type lookup. function makeFreshConnectContext() { let call = 0 return makeContext(() => { call++ if (call === 1) return makeChain({ data: null }) if (call === 2) return makeChain({ data: [] }) + if (call === 3) return makeChain({ data: null }) return makeChain({ data: { id: 'new-conn' } }) }) } @@ -340,3 +348,146 @@ describe('POST /connect auth-method pinning wired into startAuthorization', () = expect(args[5]).toBe('BANKID') }) }) + +describe('POST /connect existing-connection guard', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireCapability).mockResolvedValue(null) + mockGetPreferredAuthMethod.mockResolvedValue(undefined) + mockStartAuthorization.mockResolvedValue({ + url: 'https://bank.example/auth', + authorization_id: 'auth-1', + }) + }) + + it('returns 409 EXISTING_CONNECTION when a dead (expired) row for the same bank exists', async () => { + const chains: RecordedChain[] = [] + let call = 0 + const ctx = makeContext(() => { + call++ + let chain: RecordedChain + if (call === 1) { + // No live pending attempt. + chain = makeChain({ data: null }) + } else if (call === 2) { + // Sweep finds nothing to delete. + chain = makeChain({ data: [] }) + } else { + // Guard: an established expired row for this bank survives the sweep. + chain = makeChain({ data: { id: 'existing-1', status: 'expired' } }) + } + chains.push(chain) + return chain + }) + + const response = await connectRoute().handler(makeConnectRequest(), ctx) + + expect(response.status).toBe(409) + const body = (await response.json()) as { + code: string + existing_connection_id: string + error: string + } + expect(body.code).toBe('EXISTING_CONNECTION') + expect(body.existing_connection_id).toBe('existing-1') + // The message names the bank and points at Förnya samtycke. + expect(body.error).toContain('Nordea') + expect(body.error).toContain('behöver förnyas') + expect(body.error).toContain('Förnya samtycke') + // The bank flow is never started and no duplicate row is inserted. + expect(mockStartAuthorization).not.toHaveBeenCalled() + for (const chain of chains) { + expect(chain._calls.some((c) => c.method === 'insert')).toBe(false) + } + // The guard only matches DEAD-BUT-ESTABLISHED rows: an active row (a + // legitimate second login at the same bank) and revoked rows never 409. + const guard = chains[2] + const inCall = guard._calls.find((c) => c.method === 'in') + expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']]) + }) + + it('never 409s over an ACTIVE same-bank connection: the guard status filter excludes it', async () => { + const chains: RecordedChain[] = [] + let call = 0 + const ctx = makeContext(() => { + call++ + let chain: RecordedChain + if (call === 1) { + chain = makeChain({ data: null }) + } else if (call === 2) { + chain = makeChain({ data: [] }) + } else if (call === 3) { + // Guard: only an ACTIVE row exists for this bank; the dead-status + // filter matches nothing, so the query returns null. + chain = makeChain({ data: null }) + } else { + chain = makeChain({ data: { id: 'second-login' } }) + } + chains.push(chain) + return chain + }) + + const response = await connectRoute().handler(makeConnectRequest(), ctx) + + // The second legitimate login at the same bank goes through. + expect(response.status).toBe(200) + const body = (await response.json()) as { connection_id: string } + expect(body.connection_id).toBe('second-login') + expect(mockStartAuthorization).toHaveBeenCalledTimes(1) + + // The guard queried with the dead-status filter (so an active row can + // never be returned) rather than neq('status', 'revoked'). + const guard = chains[2] + const inCall = guard._calls.find((c) => c.method === 'in') + expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']]) + expect(guard._calls.some((c) => c.method === 'neq')).toBe(false) + }) + + it('force_new: true bypasses the guard and inserts a fresh row', async () => { + const chains: RecordedChain[] = [] + let call = 0 + const ctx = makeContext(() => { + call++ + let chain: RecordedChain + if (call === 1) { + chain = makeChain({ data: null }) + } else if (call === 2) { + chain = makeChain({ data: [] }) + } else { + // With force_new the guard query is skipped entirely: call 3 is the + // insert of the fresh connection row. + chain = makeChain({ data: { id: 'new-conn' } }) + } + chains.push(chain) + return chain + }) + + const req = new Request('https://test.local/api/extensions/ext/enable-banking/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + aspsp_name: 'Nordea', + aspsp_country: 'SE', + psu_type: 'business', + force_new: true, + }), + }) + + const response = await connectRoute().handler(req, ctx) + + expect(response.status).toBe(200) + const body = (await response.json()) as { connection_id: string } + expect(body.connection_id).toBe('new-conn') + expect(mockStartAuthorization).toHaveBeenCalledTimes(1) + // No guard query ran: nothing filtered on the guard's dead-status set + // (the sweep's in() uses ['pending', 'error'] and is expected). + for (const chain of chains) { + const guardIn = chain._calls.find( + (c) => + c.method === 'in' && + JSON.stringify(c.args) === JSON.stringify(['status', ['expired', 'error', 'pending_selection']]), + ) + expect(guardIn).toBeUndefined() + } + }) +}) diff --git a/extensions/general/enable-banking/__tests__/session-expired.test.ts b/extensions/general/enable-banking/__tests__/session-expired.test.ts index bf18c1e1..0b29e7c3 100644 --- a/extensions/general/enable-banking/__tests__/session-expired.test.ts +++ b/extensions/general/enable-banking/__tests__/session-expired.test.ts @@ -105,6 +105,9 @@ function makeContext(connection: Record, updateSpy: Mock, inser const chain: any = {} chain.select = vi.fn(() => chain) chain.eq = vi.fn(() => chain) + // The fresh-connect existing-connection guard chains .neq('status', 'revoked') + // and resolves via maybeSingle (null here: no established row). + chain.neq = vi.fn(() => chain) chain.gte = vi.fn(() => chain) chain.limit = vi.fn(() => chain) chain.order = vi.fn(() => chain) diff --git a/extensions/general/enable-banking/__tests__/supersede.test.ts b/extensions/general/enable-banking/__tests__/supersede.test.ts new file mode 100644 index 00000000..3af60215 --- /dev/null +++ b/extensions/general/enable-banking/__tests__/supersede.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' + +const { mockDeleteSession, mockCountLiveSiblings } = vi.hoisted(() => ({ + mockDeleteSession: vi.fn(), + mockCountLiveSiblings: vi.fn(), +})) + +vi.mock('../lib/api-client', () => ({ + deleteSession: (...args: unknown[]) => mockDeleteSession(...args), +})) +vi.mock('../lib/session-sharing', () => ({ + countLiveSiblings: (...args: unknown[]) => mockCountLiveSiblings(...args), +})) + +import { supersedeSiblingConnections } from '../lib/supersede' +import { eventBus } from '@/lib/events/bus' +import type { StoredAccount } from '../types' + +interface RecordedCall { + method: string + args: unknown[] +} + +interface RecordedChain { + _calls: RecordedCall[] + [key: string]: unknown +} + +function makeChain(result: { data?: unknown; error?: unknown } = {}): RecordedChain { + const calls: RecordedCall[] = [] + const chain: Record = { _calls: calls } + for (const m of ['select', 'eq', 'neq', 'in', 'order', 'limit', 'update', 'delete']) { + chain[m] = vi.fn((...args: unknown[]) => { + calls.push({ method: m, args }) + return chain + }) + } + chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null }) + chain.maybeSingle = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null }) + chain.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return chain as RecordedChain +} + +interface ScriptStep { + table: string + chain: RecordedChain +} + +/** from() dispatcher that asserts the table order and hands out scripted chains. */ +function makeSupabase(script: ScriptStep[]): { client: SupabaseClient; from: ReturnType } { + let i = 0 + const from = vi.fn((table: string) => { + const step = script[i] + i++ + expect(step, `unexpected from('${table}') call #${i}`).toBeDefined() + expect(table).toBe(step.table) + return step.chain + }) + return { client: { from } as unknown as SupabaseClient, from } +} + +function updatePayload(chain: RecordedChain): Record { + const call = chain._calls.find((c) => c.method === 'update') + expect(call, 'expected an update on this chain').toBeDefined() + return call!.args[0] as Record +} + +const BASE_INPUT = { + companyId: 'company-1', + userId: 'user-1', + newConnectionId: 'new-1', + bankName: 'TestBank', + newSessionId: 'sess-new', +} + +function makeSibling(overrides: Record = {}) { + return { + id: 'old-1', + status: 'expired', + session_id: 'sess-old', + accounts_data: [ + { + uid: 'uid-old', + iban: 'SE45 5000 0000 0583 9825 7466', + currency: 'SEK', + dedup_scope: 'legacy-scope', + }, + ] as StoredAccount[], + last_synced_at: '2026-08-01T00:00:00Z', + initial_sync_completed_at: '2026-06-01T00:00:00Z', + initial_sync_requested_from: '2026-01-01', + initial_sync_returned_min_date: '2026-01-02', + initial_sync_returned_max_date: '2026-07-31', + initial_sync_lookback_days: 365, + ...overrides, + } +} + +const NEW_ACCOUNTS: StoredAccount[] = [ + { uid: 'uid-new', iban: 'SE4550000000058398257466', currency: 'SEK' }, +] + +describe('supersedeSiblingConnections', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockCountLiveSiblings.mockResolvedValue(0) + mockDeleteSession.mockResolvedValue(undefined) + }) + + it('supersedes an IBAN-overlapping sibling: revoked + superseded_by, transactions re-pointed, claims released', async () => { + const emitSpy = vi.spyOn(eventBus, 'emit') + + const siblingSelect = makeChain({ data: [makeSibling()] }) + const revokeUpdate = makeChain({}) + const txSelect = makeChain({ data: [{ id: 't1' }, { id: 't2' }] }) + const txUpdate = makeChain({}) + const cashDemote = makeChain({}) + const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } }) + const carryUpdate = makeChain({}) + + const { client } = makeSupabase([ + { table: 'bank_connections', chain: siblingSelect }, + { table: 'bank_connections', chain: revokeUpdate }, + { table: 'transactions', chain: txSelect }, + { table: 'transactions', chain: txUpdate }, + { table: 'cash_accounts', chain: cashDemote }, + { table: 'bank_connections', chain: newRowSelect }, + { table: 'bank_connections', chain: carryUpdate }, + ]) + + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + newAccounts: NEW_ACCOUNTS, + }) + + expect(result.supersededIds).toEqual(['old-1']) + // The explicit dedup scope travels, keyed by normalized IBAN. + expect(result.dedupScopeByIban.get('SE4550000000058398257466')).toBe('legacy-scope') + + // The dead consent is revoked at EB (nobody else shares it). + expect(mockDeleteSession).toHaveBeenCalledWith('sess-old') + + // The row is parked, not deleted: revoked + superseded_by disambiguates + // a supersede from a user disconnect. + const parked = updatePayload(revokeUpdate) + expect(parked.status).toBe('revoked') + expect(parked.session_id).toBeNull() + expect(parked.superseded_by).toBe('new-1') + expect(typeof parked.superseded_at).toBe('string') + + // Feed rows follow the survivor, batch-scoped by the selected ids. + expect(updatePayload(txUpdate)).toEqual({ bank_connection_id: 'new-1' }) + const inCall = txUpdate._calls.find((c) => c.method === 'in') + expect(inCall?.args).toEqual(['id', ['t1', 't2']]) + + // Leftover ledger claims are demoted to manual, mirroring /disconnect. + expect(updatePayload(cashDemote)).toEqual({ bank_connection_id: null }) + + // Sync state is carried onto the survivor so neither the cron's + // first-sync backfill nor the picker treats the renewal as a first connect. + const carried = updatePayload(carryUpdate) + expect(carried.last_synced_at).toBe('2026-08-01T00:00:00Z') + expect(carried.initial_sync_completed_at).toBe('2026-06-01T00:00:00Z') + expect(carried.initial_sync_requested_from).toBe('2026-01-01') + expect(carried.initial_sync_lookback_days).toBe(365) + + expect(emitSpy).toHaveBeenCalledWith({ + type: 'bank_connection.superseded', + payload: { + connectionId: 'old-1', + supersededById: 'new-1', + bankName: 'TestBank', + userId: 'user-1', + companyId: 'company-1', + }, + }) + }) + + it('parks the sibling row BEFORE revoking its session at Enable Banking', async () => { + // Revoking first and then failing to park would leave a live-looking row + // whose session is already dead at the bank: the park update must come + // first, in call order. + const sequence: string[] = [] + mockDeleteSession.mockImplementation(async () => { + sequence.push('deleteSession') + }) + + const siblingSelect = makeChain({ data: [makeSibling()] }) + const revokeUpdate = makeChain({}) + const originalUpdate = revokeUpdate.update as ReturnType + revokeUpdate.update = vi.fn((...args: unknown[]) => { + sequence.push('parkUpdate') + return originalUpdate(...args) + }) + const txSelect = makeChain({ data: [] }) + const cashDemote = makeChain({}) + const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } }) + const carryUpdate = makeChain({}) + + const { client } = makeSupabase([ + { table: 'bank_connections', chain: siblingSelect }, + { table: 'bank_connections', chain: revokeUpdate }, + { table: 'transactions', chain: txSelect }, + { table: 'cash_accounts', chain: cashDemote }, + { table: 'bank_connections', chain: newRowSelect }, + { table: 'bank_connections', chain: carryUpdate }, + ]) + + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + newAccounts: NEW_ACCOUNTS, + }) + + expect(result.supersededIds).toEqual(['old-1']) + expect(sequence).toEqual(['parkUpdate', 'deleteSession']) + }) + + it('skips the EB session revoke entirely when the park update fails', async () => { + const siblingSelect = makeChain({ data: [makeSibling()] }) + const failedPark = makeChain({ error: { message: 'update refused' } }) + + // Only the lookup and the failed park run: no revoke, no re-point, no + // demote, no sync-state carry. + const { client, from } = makeSupabase([ + { table: 'bank_connections', chain: siblingSelect }, + { table: 'bank_connections', chain: failedPark }, + ]) + + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + newAccounts: NEW_ACCOUNTS, + }) + + expect(result.supersededIds).toEqual([]) + expect(mockDeleteSession).not.toHaveBeenCalled() + expect(from).toHaveBeenCalledTimes(2) + }) + + it('never supersedes an ACTIVE sibling without IBAN overlap (separate login at the same bank)', async () => { + const siblingSelect = makeChain({ + data: [ + makeSibling({ + id: 'other-login', + status: 'active', + accounts_data: [{ uid: 'uid-x', iban: 'SE9999999999999999999999', currency: 'SEK' }], + }), + ], + }) + const { client, from } = makeSupabase([{ table: 'bank_connections', chain: siblingSelect }]) + + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + newAccounts: NEW_ACCOUNTS, + }) + + expect(result.supersededIds).toEqual([]) + // Only the sibling lookup ran: nothing was updated, revoked, or re-pointed. + expect(from).toHaveBeenCalledTimes(1) + expect(mockDeleteSession).not.toHaveBeenCalled() + }) + + it('parks the row but keeps the EB session when other connections still share it', async () => { + mockCountLiveSiblings.mockResolvedValue(2) + + const siblingSelect = makeChain({ data: [makeSibling()] }) + const revokeUpdate = makeChain({}) + const txSelect = makeChain({ data: [] }) + const cashDemote = makeChain({}) + const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } }) + const carryUpdate = makeChain({}) + + const { client } = makeSupabase([ + { table: 'bank_connections', chain: siblingSelect }, + { table: 'bank_connections', chain: revokeUpdate }, + { table: 'transactions', chain: txSelect }, + { table: 'cash_accounts', chain: cashDemote }, + { table: 'bank_connections', chain: newRowSelect }, + { table: 'bank_connections', chain: carryUpdate }, + ]) + + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + newAccounts: NEW_ACCOUNTS, + }) + + expect(result.supersededIds).toEqual(['old-1']) + // A shared consent is never revoked upstream; the row is still parked. + expect(mockDeleteSession).not.toHaveBeenCalled() + expect(updatePayload(revokeUpdate).status).toBe('revoked') + }) + + it('matches a DEAD sibling on bank identity alone only when neither side has IBANs', async () => { + const siblingSelect = makeChain({ + data: [ + makeSibling({ + session_id: null, + accounts_data: [{ uid: 'uid-old', currency: 'SEK' }], + initial_sync_completed_at: null, + last_synced_at: null, + }), + ], + }) + const revokeUpdate = makeChain({}) + const txSelect = makeChain({ data: [] }) + const cashDemote = makeChain({}) + const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } }) + + const { client } = makeSupabase([ + { table: 'bank_connections', chain: siblingSelect }, + { table: 'bank_connections', chain: revokeUpdate }, + { table: 'transactions', chain: txSelect }, + { table: 'cash_accounts', chain: cashDemote }, + { table: 'bank_connections', chain: newRowSelect }, + ]) + + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + newAccounts: [{ uid: 'uid-new', currency: 'SEK' }], + }) + + expect(result.supersededIds).toEqual(['old-1']) + expect(updatePayload(revokeUpdate).superseded_by).toBe('new-1') + }) + + it('does nothing without a bank name', async () => { + const { client, from } = makeSupabase([]) + const result = await supersedeSiblingConnections(client, { + ...BASE_INPUT, + bankName: null, + newAccounts: NEW_ACCOUNTS, + }) + expect(result.supersededIds).toEqual([]) + expect(from).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index dafd60db..de200c9a 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -263,11 +263,29 @@ export function AccountPickerDialog({ if (!open || !isInitialSelection || !company?.id || accounts.length === 0) return let cancelled = false ;(async () => { + // Include rows this connection superseded: a renewal that arrived via a + // fresh connect owns no transactions until the callback's supersede has + // re-pointed them, and the gap-fill default must not depend on winning + // that race. A failed lookup (e.g. column not deployed yet) falls back + // to probing this connection alone. + let probeConnectionIds: string[] = [connectionId] + const { data: supersededRows, error: supersededError } = await supabase + .from('bank_connections') + .select('id') + .eq('company_id', company.id) + .eq('superseded_by', connectionId) + if (cancelled) return + if (!supersededError && supersededRows) { + probeConnectionIds = [ + connectionId, + ...(supersededRows as Array<{ id: string }>).map((r) => r.id), + ] + } const { data, error } = await supabase .from('transactions') .select('date') .eq('company_id', company.id) - .eq('bank_connection_id', connectionId) + .in('bank_connection_id', probeConnectionIds) .order('date', { ascending: false }) .limit(1) .maybeSingle() diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index eb1ba34d..b4e61d2a 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -294,7 +294,7 @@ export const enableBankingExtension: Extension = { const blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync) if (blocked) return blocked - const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId } = await request.json() + const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId, force_new: forceNew } = await request.json() // Reconnect mode: re-authorize an EXISTING connection in place (no // disconnect required). The aspsp identity falls back to the stored row @@ -460,6 +460,46 @@ export const enableBankingExtension: Extension = { bank: resolvedAspspName, }) } + + // A fresh connect while a DEAD-BUT-ESTABLISHED connection to the + // same bank exists (expired/error/pending_selection) is almost + // always a renewal that should go through the reconnect path: a + // second row duplicates the connection and used to strand the old + // one in "Åtgärd krävs" forever. 409 with the existing id lets + // the client offer "förnya i stället". An ACTIVE row never + // triggers the guard: two legitimate logins at the same bank + // (disjoint account sets, e.g. privat + företag) must remain + // creatable through the UI, and the callback's supersede leaves + // non-overlapping account sets alone. force_new stays as the + // deliberate escape hatch. Runs AFTER the sweep so a + // never-activated zombie cannot block a legitimate fresh connect. + if (forceNew !== true) { + const { data: establishedRow } = await supabase + .from('bank_connections') + .select('id, status') + .eq('company_id', companyId) + .eq('bank_name', resolvedAspspName) + .in('status', ['expired', 'error', 'pending_selection']) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + if (establishedRow) { + log.info('[enable-banking] Rejecting fresh connect: dead connection needs renewal', { + existing_id: establishedRow.id, + existing_status: establishedRow.status, + bank: resolvedAspspName, + }) + return NextResponse.json( + { + error: `Du har redan en koppling till ${resolvedAspspName} som behöver förnyas. Använd Förnya samtycke på kopplingen i stället.`, + code: 'EXISTING_CONNECTION', + existing_connection_id: establishedRow.id, + }, + { status: 409 } + ) + } + } } const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback` diff --git a/extensions/general/enable-banking/lib/__tests__/sync.test.ts b/extensions/general/enable-banking/lib/__tests__/sync.test.ts index f3404660..54e259cc 100644 --- a/extensions/general/enable-banking/lib/__tests__/sync.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/sync.test.ts @@ -372,6 +372,88 @@ describe('syncAccountTransactions', () => { expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0']) }) + it('keeps external_ids identical across a uid change when dedup_scope is preserved', async () => { + // The renewal case for accounts WITHOUT an IBAN: many ASPSPs mint a fresh + // uid on re-authorization. dedup_scope pins the scope of the first ingest, + // so the re-synced ids must be byte-identical and collide on + // (company_id, external_id) instead of re-importing the history. + mockConvertTransaction.mockReturnValue({ + id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test', + }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + mockGetAllTransactionsWithRaw.mockResolvedValueOnce({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }], + rawPages: ['{}'], + }) + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, + makeAccount({ uid: 'uid-before-renewal', dedup_scope: 'uid-before-renewal' }), + '2024-06-01', '2024-06-30', mockIngest + ) + const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id) + + // Renewed consent: the ASPSP minted a NEW uid, the carried scope survives. + mockGetAllTransactionsWithRaw.mockResolvedValueOnce({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }], + rawPages: ['{}'], + }) + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, + makeAccount({ uid: 'uid-after-renewal', dedup_scope: 'uid-before-renewal' }), + '2024-06-01', '2024-06-30', mockIngest + ) + const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id) + + expect(firstIds).toEqual(['eb_uid-before-renewal_2024-06-15_10000_0']) + expect(secondIds).toEqual(firstIds) + }) + + it('prefers dedup_scope over both IBAN and uid for the external_id account scope', async () => { + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }], + rawPages: ['{}'], + }) + mockConvertTransaction.mockReturnValue({ + id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test', + }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, + makeAccount({ iban: 'SE4550000000058398257466', dedup_scope: 'pinned-scope' }), + '2024-06-01', '2024-06-30', mockIngest + ) + + const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id) + expect(ids).toEqual(['eb_pinned-scope_2024-06-15_10000_0']) + }) + + it('stamps dedup_scope with the scope used, so the accounts_data write-back persists it', async () => { + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }], + rawPages: ['{}'], + }) + mockConvertTransaction.mockReturnValue({ + id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test', + }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + const withIban = makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' }) + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, withIban, + '2024-06-01', '2024-06-30', mockIngest + ) + expect(withIban.dedup_scope).toBe('SE4550000000058398257466') + + const withoutIban = makeAccount() + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, withoutIban, + '2024-06-01', '2024-06-30', mockIngest + ) + expect(withoutIban.dedup_scope).toBe('acc-uid-1') + }) + it('reproduces the same SET of external_ids when a re-sync returns transactions in a different order', async () => { // Two genuinely distinct same-day/same-amount transactions. A later sync may // return them in any order; the dedupe guarantee is that the id SET is diff --git a/extensions/general/enable-banking/lib/supersede.ts b/extensions/general/enable-banking/lib/supersede.ts new file mode 100644 index 00000000..e2139a17 --- /dev/null +++ b/extensions/general/enable-banking/lib/supersede.ts @@ -0,0 +1,399 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { normalizeIban } from '@/lib/cash-accounts/service' +import { eventBus } from '@/lib/events/bus' +import { deleteSession } from './api-client' +import { countLiveSiblings } from './session-sharing' +import type { StoredAccount } from '../types' + +const log = createLogger('enable-banking/supersede') + +/** + * transactions.bank_connection_id is re-pointed id-batch by id-batch instead + * of one unbounded UPDATE: a long-lived connection can hold years of feed + * rows, and a single statement over all of them risks the platform statement + * timeout mid-callback. + */ +const REPOINT_BATCH_SIZE = 500 + +export interface SupersedeInput { + companyId: string + userId: string + /** The connection that just completed the callback (the survivor). */ + newConnectionId: string + bankName: string | null + /** Session the surviving row now holds; a sibling on the SAME session is never revoked at EB. */ + newSessionId: string | null + /** Accounts the new session returned (for IBAN-overlap matching). */ + newAccounts: readonly StoredAccount[] +} + +export interface SupersedeResult { + /** Sibling rows parked as revoked + superseded_by. */ + supersededIds: string[] + /** + * dedup scopes carried from superseded siblings, keyed by normalized IBAN. + * The caller applies these to the surviving row's accounts_data so a + * renewal keeps minting the same transaction external_ids. + */ + dedupScopeByIban: Map +} + +interface SiblingRow { + id: string + status: string + session_id: string | null + accounts_data: StoredAccount[] | null + last_synced_at: string | null + initial_sync_completed_at: string | null + initial_sync_requested_from: string | null + initial_sync_returned_min_date: string | null + initial_sync_returned_max_date: string | null + initial_sync_lookback_days: number | null +} + +/** + * Park every older connection row this (re)connect replaces. + * + * A renewal performed via the bank list ("Anslut ny bank") used to leave the + * previous row in 'expired' forever: an eternal "Åtgärd krävs" card, a red + * status chip, and the transaction history stranded on the dead row so the + * picker's gap-fill probe read the renewal as a first connect. This helper + * runs from the OAuth callback after the surviving row is updated: + * + * 1. Match siblings (same company, same bank, live-ish status) by IBAN + * overlap. An ACTIVE sibling with no overlap is never touched: two logins + * at the same bank (e.g. SEB privat + företag) legitimately coexist. + * When NEITHER side carries any IBAN, a non-active sibling is matched on + * bank identity alone (logged): those rows cannot be proven either way + * and leaving them would strand the duplicate forever. + * 2. Best-effort revoke the sibling's EB session, but only when no other + * connection still shares it (countLiveSiblings, same rule as + * /disconnect and the reconnect path). + * 3. Park the row: status 'revoked' (reused deliberately: every existing + * filter, ledger-claim release, and cron skip already handles it) plus + * superseded_by/superseded_at so it stays distinguishable from a user + * disconnect. + * 4. Re-point the sibling's transactions at the surviving row (feed + * metadata only: journal tables are never touched) so gap-fill probes, + * per-connection scoping, and "sedan sist" counters keep working. + * 5. Demote leftover cash_accounts rows to manual, mirroring /disconnect; + * the callback's own IBAN mirror then promotes them onto the survivor. + * 6. Carry sync state (last_synced_at, initial_sync_*) onto the surviving + * row when it has none, so neither the cron's first-sync backfill nor + * the picker treats a renewal as a first connect. + * + * Must run on a service-role client (RLS would hide nothing here, but + * countLiveSiblings requires it, and the callback already holds one). + * Idempotent: a re-run finds no live siblings and does nothing. + */ +export async function supersedeSiblingConnections( + supabase: SupabaseClient, + input: SupersedeInput, +): Promise { + const result: SupersedeResult = { supersededIds: [], dedupScopeByIban: new Map() } + if (!input.bankName) return result + + const { data: siblingRows, error: siblingError } = await supabase + .from('bank_connections') + .select( + 'id, status, session_id, accounts_data, last_synced_at, initial_sync_completed_at, initial_sync_requested_from, initial_sync_returned_min_date, initial_sync_returned_max_date, initial_sync_lookback_days', + ) + .eq('company_id', input.companyId) + .eq('bank_name', input.bankName) + .neq('id', input.newConnectionId) + .in('status', ['active', 'expired', 'error', 'pending_selection']) + + if (siblingError) { + log.warn('sibling lookup failed, superseding nothing', { + companyId: input.companyId, + newConnectionId: input.newConnectionId, + error: siblingError.message, + }) + return result + } + const siblings = (siblingRows ?? []) as SiblingRow[] + if (siblings.length === 0) return result + + const newIbans = new Set() + for (const account of input.newAccounts) { + const iban = normalizeIban(account.iban) + if (iban) newIbans.add(iban) + } + + const superseded: SiblingRow[] = [] + for (const sibling of siblings) { + const siblingAccounts = sibling.accounts_data ?? [] + const siblingIbans = new Set() + for (const account of siblingAccounts) { + const iban = normalizeIban(account.iban) + if (iban) siblingIbans.add(iban) + } + + const overlap = [...siblingIbans].some((iban) => newIbans.has(iban)) + if (!overlap) { + // Without IBAN overlap we cannot prove this is the same account set. + // An active sibling stays untouched (it may be a genuinely different + // login at the same bank); a dead sibling is matched on bank identity + // alone only when NEITHER side carries any IBAN at all. + const neitherHasIbans = newIbans.size === 0 && siblingIbans.size === 0 + if (sibling.status === 'active' || !neitherHasIbans) { + log.info('sibling left alone: no IBAN overlap', { + siblingId: sibling.id, + siblingStatus: sibling.status, + newConnectionId: input.newConnectionId, + }) + continue + } + log.warn('superseding no-IBAN sibling on bank identity alone', { + siblingId: sibling.id, + siblingStatus: sibling.status, + newConnectionId: input.newConnectionId, + }) + } + + // Park the row FIRST, revoke at Enable Banking only after the park + // succeeded: revoking first and then failing to park would leave a + // live-looking row whose session is already dead at the bank, with no + // way back but another full reconnect. + const { error: updateError } = await supabase + .from('bank_connections') + .update({ + status: 'revoked', + session_id: null, + oauth_state: null, + error_message: null, + superseded_by: input.newConnectionId, + superseded_at: new Date().toISOString(), + }) + .eq('id', sibling.id) + .eq('company_id', input.companyId) + + if (updateError) { + log.error('failed to park superseded sibling: skipping its EB session revoke', { + siblingId: sibling.id, + error: updateError.message, + }) + continue + } + + // Best-effort revoke the replaced consent at Enable Banking. Never revoke + // a session another connection still holds (shared cross-company + // consents, see lib/session-sharing.ts), and never revoke the session the + // surviving row itself now carries. + if (sibling.session_id && sibling.session_id !== input.newSessionId) { + const stillShared = + (await countLiveSiblings(supabase, sibling.session_id, sibling.id)) > 0 + if (stillShared) { + log.info('sibling session shared with other connections: not revoking at EB', { + siblingId: sibling.id, + }) + } else { + try { + await deleteSession(sibling.session_id) + } catch (revokeError) { + log.warn('sibling session revoke skipped (likely already expired)', { + siblingId: sibling.id, + message: revokeError instanceof Error ? revokeError.message : String(revokeError), + }) + } + } + } + superseded.push(sibling) + result.supersededIds.push(sibling.id) + + // Carry the dedup scope of every IBAN-identified account forward so the + // surviving row keeps minting the SAME transaction external_ids (the + // scope may be an old provider uid for accounts whose IBAN appeared + // later). Only explicit scopes travel: a missing one means the sibling's + // ids were derived from the IBAN, which the survivor derives identically. + for (const account of siblingAccounts) { + const iban = normalizeIban(account.iban) + if (iban && account.dedup_scope && !result.dedupScopeByIban.has(iban)) { + result.dedupScopeByIban.set(iban, account.dedup_scope) + } + } + + await repointTransactions(supabase, input.companyId, sibling.id, input.newConnectionId) + + // Release the sibling's ledger claims, mirroring /disconnect: the + // callback's IBAN mirror promotes the (now manual) holders onto the + // surviving connection so the same bank lands back on its BAS account. + const { error: demoteError } = await supabase + .from('cash_accounts') + .update({ bank_connection_id: null }) + .eq('company_id', input.companyId) + .eq('bank_connection_id', sibling.id) + if (demoteError) { + log.error('failed to release superseded sibling cash_accounts claims', { + siblingId: sibling.id, + error: demoteError.message, + }) + } + + try { + await eventBus.emit({ + type: 'bank_connection.superseded', + payload: { + connectionId: sibling.id, + supersededById: input.newConnectionId, + bankName: input.bankName, + userId: input.userId, + companyId: input.companyId, + }, + }) + } catch (emitError) { + log.error('failed to emit bank_connection.superseded', emitError as Error, { + siblingId: sibling.id, + }) + } + } + + if (superseded.length > 0) { + await carrySyncStateForward(supabase, input.newConnectionId, superseded) + log.info('superseded sibling connections', { + newConnectionId: input.newConnectionId, + supersededIds: result.supersededIds, + }) + } + + return result +} + +/** + * Copy sync bookkeeping from the superseded rows onto the survivor when the + * survivor has none: without this the cron's first-sync backfill path + * (gated on initial_sync_completed_at IS NULL) re-runs a deep history pull + * for a bank that was already backfilled, which is one of the duplicate + * floods this module exists to stop. + */ +async function carrySyncStateForward( + supabase: SupabaseClient, + newConnectionId: string, + superseded: readonly SiblingRow[], +): Promise { + const { data: newRow, error: newRowError } = await supabase + .from('bank_connections') + .select('last_synced_at, initial_sync_completed_at') + .eq('id', newConnectionId) + .single() + + if (newRowError || !newRow) { + log.warn('could not read surviving row for sync-state carry-forward', { + newConnectionId, + error: newRowError?.message, + }) + return + } + const survivor = newRow as { last_synced_at: string | null; initial_sync_completed_at: string | null } + + const maxLastSynced = superseded + .map((s) => s.last_synced_at) + .filter((v): v is string => Boolean(v)) + .sort() + .pop() + + const donor = superseded + .filter((s) => Boolean(s.initial_sync_completed_at)) + .sort((a, b) => (a.initial_sync_completed_at! < b.initial_sync_completed_at! ? 1 : -1))[0] + + const carryLastSynced = !survivor.last_synced_at && maxLastSynced + const carryInitialSync = !survivor.initial_sync_completed_at && donor + if (!carryLastSynced && !carryInitialSync) return + + // Payloads stay object literals per branch (never a built-up/spread record) + // so the no-phantom-columns guard can verify the column names. + const { error: carryError } = + carryLastSynced && carryInitialSync + ? await supabase + .from('bank_connections') + .update({ + last_synced_at: maxLastSynced, + initial_sync_completed_at: donor!.initial_sync_completed_at, + initial_sync_requested_from: donor!.initial_sync_requested_from, + initial_sync_returned_min_date: donor!.initial_sync_returned_min_date, + initial_sync_returned_max_date: donor!.initial_sync_returned_max_date, + initial_sync_lookback_days: donor!.initial_sync_lookback_days, + }) + .eq('id', newConnectionId) + : carryLastSynced + ? await supabase + .from('bank_connections') + .update({ last_synced_at: maxLastSynced }) + .eq('id', newConnectionId) + : await supabase + .from('bank_connections') + .update({ + initial_sync_completed_at: donor!.initial_sync_completed_at, + initial_sync_requested_from: donor!.initial_sync_requested_from, + initial_sync_returned_min_date: donor!.initial_sync_returned_min_date, + initial_sync_returned_max_date: donor!.initial_sync_returned_max_date, + initial_sync_lookback_days: donor!.initial_sync_lookback_days, + }) + .eq('id', newConnectionId) + + if (carryError) { + log.error('failed to carry sync state onto surviving connection', { + newConnectionId, + error: carryError.message, + }) + } +} + +/** + * Move the superseded row's feed rows onto the survivor. This is transaction + * METADATA (the plain ON DELETE SET NULL FK), never journal tables: no BFL + * immutability or period-lock trigger is in play. Batched by id so a + * years-long feed cannot blow the statement timeout inside the callback. + */ +async function repointTransactions( + supabase: SupabaseClient, + companyId: string, + fromConnectionId: string, + toConnectionId: string, +): Promise { + let total = 0 + for (;;) { + const { data: rows, error: selectError } = await supabase + .from('transactions') + .select('id') + .eq('company_id', companyId) + .eq('bank_connection_id', fromConnectionId) + .limit(REPOINT_BATCH_SIZE) + + if (selectError) { + log.error('transaction re-point select failed', { + fromConnectionId, + error: selectError.message, + }) + break + } + const ids = ((rows ?? []) as Array<{ id: string }>).map((r) => r.id) + if (ids.length === 0) break + + const { error: updateError } = await supabase + .from('transactions') + .update({ bank_connection_id: toConnectionId }) + .eq('company_id', companyId) + .in('id', ids) + + if (updateError) { + log.error('transaction re-point update failed', { + fromConnectionId, + toConnectionId, + error: updateError.message, + }) + break + } + total += ids.length + if (ids.length < REPOINT_BATCH_SIZE) break + } + + if (total > 0) { + log.info('re-pointed transactions onto superseding connection', { + fromConnectionId, + toConnectionId, + count: total, + }) + } +} diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 02cafb82..961e0008 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -152,7 +152,17 @@ export async function syncAccountTransactions( // formatting variants from the ASPSP ("SE45 5000 …" vs "SE455000…") don't // change the scope and orphan every prior external_id. Falls back to the // provider account uid. - const accountScope = account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid + // + // account.dedup_scope, when present, wins outright: it pins the scope the + // account was FIRST ingested under, so a re-authorization that mints a new + // uid (common for no-IBAN accounts) keeps producing byte-identical ids + // instead of re-importing the whole history. The id FORMAT itself is frozen + // (see lib/transactions/external-id.ts); only the scope input is stabilized. + // Stamped back onto the account here for legacy rows so the caller's + // accounts_data write-back persists it. + const accountScope = + account.dedup_scope || account.iban?.replace(/\s+/g, '').toUpperCase() || account.uid + if (!account.dedup_scope) account.dedup_scope = accountScope const externalIds = buildStableExternalIds( 'eb', accountScope, diff --git a/extensions/general/enable-banking/types.ts b/extensions/general/enable-banking/types.ts index dcbc8aa7..1d742a4d 100644 --- a/extensions/general/enable-banking/types.ts +++ b/extensions/general/enable-banking/types.ts @@ -16,6 +16,13 @@ export interface StoredAccount { // mapping engine default (1930). Lets multicurrency setups route SEK→1930, // EUR→1932, USD→1933, etc., so year-end FX revaluation is clean. ledger_account?: string + // The account scope used when deriving transaction external_ids at first + // ingest (the normalized IBAN, or the provider uid the account had then). + // Persisted so re-authorizations that mint a NEW uid keep producing the + // SAME external_ids for accounts without an IBAN, instead of re-importing + // the whole history. lib/sync.ts falls back to IBAN-then-uid when unset + // (rows that predate this field) and stamps it on the next sync. + dedup_scope?: string } // Re-export API types from the client diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index 079af910..09f44e39 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -62,6 +62,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [ 'bank_connection.consent_granted', 'bank_connection.account_selection_changed', 'bank_connection.revoked', + 'bank_connection.superseded', 'bank_connection.cash_account_mirror_failed', ] diff --git a/lib/events/types.ts b/lib/events/types.ts index 9f071d3e..6d0cc17e 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -59,6 +59,11 @@ export type CoreEvent = | { type: 'bank_connection.consent_granted'; payload: { connectionId: string; bankName: string | null; accountCount: number; consentExpiresAt: string | null; userId: string; companyId: string } } | { type: 'bank_connection.account_selection_changed'; payload: { connectionId: string; bankName: string | null; previousStatus: string; newStatus: string; enabledCount: number; totalCount: number; userId: string; companyId: string } } | { type: 'bank_connection.revoked'; payload: { connectionId: string; bankName: string | null; userId: string; companyId: string } } + // Emitted when a new or renewed connection supersedes an older row for the + // same bank in the same company: the old row is parked as 'revoked' with + // superseded_by pointing at the replacement, and its transactions are + // re-pointed. connectionId is the SUPERSEDED (old) row, mirroring .revoked. + | { type: 'bank_connection.superseded'; payload: { connectionId: string; supersededById: string; bankName: string | null; userId: string; companyId: string } } // Emitted when the PSD2 callback fails to mirror a returned account into // cash_accounts. ASVS V16 / ISO 27001 A.8.15: security-relevant failures // must land in a structured audit log (event_log, 30-day TTL) rather than diff --git a/messages/en.json b/messages/en.json index 4a4c7122..9759c638 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5708,6 +5708,8 @@ "bank_sync_new_since_last_visit_one": "1 new bank transaction since your last visit", "bank_sync_new_since_last_visit_many": "{count} new bank transactions since your last visit", "bank_sync_new_since_last_visit_dismiss": "Dismiss", + "bank_sync_duplicates_skipped_one": "1 already imported was skipped.", + "bank_sync_duplicates_skipped_many": "{count} already imported were skipped.", "bank_reconnect": "Reconnect", "bank_sync_session_expired": "Bank connection expired", "bank_sync_session_expired_desc": "Reconnect to keep syncing transactions.", diff --git a/messages/sv.json b/messages/sv.json index a7c0034f..854c25b9 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5708,6 +5708,8 @@ "bank_sync_new_since_last_visit_one": "1 ny banktransaktion sen ditt senaste besök", "bank_sync_new_since_last_visit_many": "{count} nya banktransaktioner sen ditt senaste besök", "bank_sync_new_since_last_visit_dismiss": "Stäng", + "bank_sync_duplicates_skipped_one": "1 redan importerad hoppades över.", + "bank_sync_duplicates_skipped_many": "{count} redan importerade hoppades över.", "bank_reconnect": "Förnya anslutning", "bank_sync_session_expired": "Bankanslutningen har löpt ut", "bank_sync_session_expired_desc": "Förnya anslutningen för att fortsätta synka transaktioner.", diff --git a/supabase/migrations/20260819175522_bank_connections_superseded_by.sql b/supabase/migrations/20260819175522_bank_connections_superseded_by.sql new file mode 100644 index 00000000..e1f08b72 --- /dev/null +++ b/supabase/migrations/20260819175522_bank_connections_superseded_by.sql @@ -0,0 +1,25 @@ +-- Bank reconnect supersede metadata. +-- +-- A successful (re)connect now supersedes any older bank_connections row for +-- the same bank in the same company (extensions/general/enable-banking/lib/ +-- supersede.ts). The superseded row reuses status 'revoked' (no CHECK change: +-- every existing filter, claim release, and cron skip already treats +-- 'revoked' correctly); superseded_by records WHICH row replaced it so a +-- supersede is distinguishable from a user disconnect and the account picker +-- can follow the chain for its gap-fill probe. +-- +-- Additive and idempotent; no RLS or trigger changes (bank_connections +-- already carries both). + +ALTER TABLE public.bank_connections + ADD COLUMN IF NOT EXISTS superseded_by uuid REFERENCES public.bank_connections(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS superseded_at timestamptz; + +-- Only superseded rows carry a value, so a partial index keeps the +-- "which rows did this connection supersede" lookup cheap without taxing +-- every other row. +CREATE INDEX IF NOT EXISTS idx_bank_connections_superseded_by + ON public.bank_connections (superseded_by) + WHERE superseded_by IS NOT NULL; + +NOTIFY pgrst, 'reload schema';