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:
@@ -1091,3 +1091,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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:<companyId>) 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,7 +41,7 @@ interface RecordedChain {
|
||||
function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain {
|
||||
const calls: RecordedCall[] = []
|
||||
const chain: Record<string, unknown> = { _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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,9 @@ function makeContext(connection: Record<string, unknown>, 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)
|
||||
|
||||
@@ -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<string, unknown> = { _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<typeof vi.fn> } {
|
||||
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<string, unknown> {
|
||||
const call = chain._calls.find((c) => c.method === 'update')
|
||||
expect(call, 'expected an update on this chain').toBeDefined()
|
||||
return call!.args[0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
const BASE_INPUT = {
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
newConnectionId: 'new-1',
|
||||
bankName: 'TestBank',
|
||||
newSessionId: 'sess-new',
|
||||
}
|
||||
|
||||
function makeSibling(overrides: Record<string, unknown> = {}) {
|
||||
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<typeof vi.fn>
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<SupersedeResult> {
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user