fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)
* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes Rebuilt branch onto main as a single commit. - import: run SIE bulk-delete RPCs on the service client to escape the 8s statement_timeout; undo_sie_import now takes an explicit actor (p_user_id) so its owner/admin gate works when auth.uid() is NULL on the service client (migration 20260624120000) + pg-real regression test - providers: distinguish missing Fortnox license from expired connection; provider_consent_tokens PK regression test - reports: include unmapped BAS expense groups in the income statement - enable-banking: reconnect closed/expired bank sessions in place - bookkeeping: surface linked invoices as underlag on the verifikat view - scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are git-ignored and consentId is now a required arg with no silent default Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): add Cache-Control header to journal entry references response --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
db8983ba9e
commit
43925bc2d3
@@ -55,6 +55,40 @@ function translateOAuthError(error: string, description: string | null): string
|
||||
return description ? `${error}: ${description}` : error
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provider OAuth authorization URL bound to an EXISTING consent id.
|
||||
* Used by both first-time connect and reconnect (token revival): the callback
|
||||
* runs exchangeAuthToken(consentId, …) which upserts the fresh tokens keyed by
|
||||
* consent_id, so re-running OAuth against the same consent overwrites a dead
|
||||
* refresh-token pair in place — no disconnect/recreate needed.
|
||||
*/
|
||||
async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): Promise<string> {
|
||||
const otc = await generateOtc(consentId)
|
||||
|
||||
// Prefer a provider-specific redirect override (e.g. VISMA_REDIRECT_URI) when
|
||||
// set — lets dev environments route through a single registered URI rather
|
||||
// than registering every ngrok URL on the OAuth client. Falls back to
|
||||
// NEXT_PUBLIC_APP_URL + the canonical callback path.
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||||
const providerRedirectEnv =
|
||||
provider === 'visma'
|
||||
? process.env.VISMA_REDIRECT_URI
|
||||
: provider === 'fortnox'
|
||||
? process.env.FORTNOX_REDIRECT_URI
|
||||
: undefined
|
||||
const callbackUrl =
|
||||
providerRedirectEnv && providerRedirectEnv.trim().length > 0
|
||||
? providerRedirectEnv
|
||||
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
|
||||
|
||||
// Encode consentId + provider in state so the callback rebinds to this consent
|
||||
const statePayload = JSON.stringify({ otc: otc.code, consentId, provider })
|
||||
const stateEncoded = Buffer.from(statePayload).toString('base64url')
|
||||
|
||||
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider Migration extension
|
||||
*
|
||||
@@ -158,10 +192,11 @@ export const arcimMigrationExtension: Extension = {
|
||||
|
||||
const companyId = ctx?.companyId ?? user.id
|
||||
|
||||
const { provider, companyName, orgNumber } = await request.json() as {
|
||||
const { provider, companyName, orgNumber, reconnect } = await request.json() as {
|
||||
provider: ArcimProvider
|
||||
companyName?: string
|
||||
orgNumber?: string
|
||||
reconnect?: boolean
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
@@ -180,8 +215,43 @@ export const arcimMigrationExtension: Extension = {
|
||||
try {
|
||||
const { createServiceClient: createSvc } = await import('@/lib/supabase/server')
|
||||
|
||||
// Reuse existing accepted consent if one exists for this provider
|
||||
const existingConsents = await listConsents(companyId)
|
||||
|
||||
// Reconnect: an existing connection's stored tokens are dead (refresh
|
||||
// failed → PROVIDER_AUTH_EXPIRED). Re-run auth against the SAME consent
|
||||
// so fresh tokens overwrite the dead pair in place — no disconnect, no
|
||||
// duplicate consent, import history preserved. Bypasses the
|
||||
// alreadyConnected short-circuit below (which would otherwise skip the
|
||||
// auth that's the whole point here).
|
||||
if (reconnect) {
|
||||
const stale = existingConsents.find(
|
||||
c => c.provider === provider && (c.status === 0 || c.status === 1),
|
||||
)
|
||||
if (stale) {
|
||||
if (ctx?.settings) {
|
||||
await ctx.settings.set('consent_id', stale.id)
|
||||
await ctx.settings.set('provider', provider)
|
||||
}
|
||||
if (providerInfo.authType === 'oauth') {
|
||||
const authUrl = await buildArcimOAuthUrl(stale.id, provider)
|
||||
return NextResponse.json({
|
||||
consentId: stale.id,
|
||||
authType: 'oauth',
|
||||
authUrl,
|
||||
reconnect: true,
|
||||
})
|
||||
}
|
||||
// Token-based providers re-authorize by re-entering credentials
|
||||
return NextResponse.json({
|
||||
consentId: stale.id,
|
||||
authType: 'token',
|
||||
reconnect: true,
|
||||
})
|
||||
}
|
||||
// No existing consent to revive — fall through to a normal connect.
|
||||
}
|
||||
|
||||
// Reuse existing accepted consent if one exists for this provider
|
||||
const accepted = existingConsents.find(c => c.provider === provider && c.status === 1)
|
||||
|
||||
if (accepted) {
|
||||
@@ -205,7 +275,11 @@ export const arcimMigrationExtension: Extension = {
|
||||
for (const p of pending) {
|
||||
const { data: tokens } = await svc
|
||||
.from('provider_consent_tokens')
|
||||
.select('id')
|
||||
// consent_id is the PK — there is no `id` column. Selecting `id`
|
||||
// errors silently (only `data` is read), so `tokens` was always
|
||||
// null and the reuse branch below never fired, deleting valid
|
||||
// status-0 consents as "abandoned".
|
||||
.select('consent_id')
|
||||
.eq('consent_id', p.id)
|
||||
.limit(1)
|
||||
if (tokens && tokens.length > 0) {
|
||||
@@ -242,37 +316,12 @@ export const arcimMigrationExtension: Extension = {
|
||||
}
|
||||
|
||||
if (providerInfo.authType === 'oauth') {
|
||||
// Generate OTC for OAuth flow
|
||||
const otc = await generateOtc(consent.id)
|
||||
|
||||
// Build the OAuth callback URL. Prefer a provider-specific override
|
||||
// (e.g. VISMA_REDIRECT_URI) when set — this lets dev environments
|
||||
// route through a single registered URI (production) rather than
|
||||
// requiring every ngrok URL to be registered on the OAuth client.
|
||||
// Falls back to NEXT_PUBLIC_APP_URL + the canonical callback path.
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
|
||||
const providerRedirectEnv =
|
||||
provider === 'visma'
|
||||
? process.env.VISMA_REDIRECT_URI
|
||||
: provider === 'fortnox'
|
||||
? process.env.FORTNOX_REDIRECT_URI
|
||||
: undefined
|
||||
const callbackUrl =
|
||||
providerRedirectEnv && providerRedirectEnv.trim().length > 0
|
||||
? providerRedirectEnv
|
||||
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
|
||||
|
||||
// Encode consentId + provider in state
|
||||
const statePayload = JSON.stringify({ otc: otc.code, consentId: consent.id, provider })
|
||||
const stateEncoded = Buffer.from(statePayload).toString('base64url')
|
||||
|
||||
const { url } = await getAuthUrl(provider, stateEncoded, callbackUrl)
|
||||
const authUrl = await buildArcimOAuthUrl(consent.id, provider)
|
||||
|
||||
return NextResponse.json({
|
||||
consentId: consent.id,
|
||||
authType: 'oauth',
|
||||
authUrl: url,
|
||||
otcCode: otc.code,
|
||||
authUrl,
|
||||
})
|
||||
} else {
|
||||
// Token-based providers: consent is ready for direct use
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
// Mock the JWT signer so api-client can build a request header without real
|
||||
// ENABLE_BANKING credentials (part 2 stubs fetch directly).
|
||||
vi.mock('../lib/jwt', () => ({
|
||||
getAuthorizationHeader: () => 'Bearer test-token',
|
||||
}))
|
||||
|
||||
// Mock the sync orchestrator so the /sync handler test can force a dead-session
|
||||
// failure without hitting the network.
|
||||
vi.mock('../lib/sync', () => ({
|
||||
syncAccountTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
isSessionExpiredResponse,
|
||||
SessionExpiredError,
|
||||
getAllTransactionsWithRaw,
|
||||
} from '../lib/api-client'
|
||||
import { enableBankingExtension } from '../index'
|
||||
import { syncAccountTransactions } from '../lib/sync'
|
||||
|
||||
const CLOSED_SESSION_BODY = JSON.stringify({
|
||||
code: 401,
|
||||
message: 'Session is closed',
|
||||
error: 'CLOSED_SESSION',
|
||||
detail: null,
|
||||
})
|
||||
|
||||
describe('isSessionExpiredResponse', () => {
|
||||
it('matches the CLOSED_SESSION 401 from the screenshot', () => {
|
||||
expect(isSessionExpiredResponse(401, CLOSED_SESSION_BODY)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the lowercase session_expired variant', () => {
|
||||
expect(isSessionExpiredResponse(401, '{"error":"session_expired"}')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'{"error":"EXPIRED_SESSION"}',
|
||||
'{"error":"INVALID_SESSION"}',
|
||||
'{"error":"SESSION_NOT_FOUND"}',
|
||||
'{"error":"WRONG_SESSION_STATUS"}',
|
||||
'{"message":"Session is closed"}',
|
||||
])('matches session-dead body %s', (body) => {
|
||||
expect(isSessionExpiredResponse(401, body)).toBe(true)
|
||||
// 403 is also a valid session-rejection status from some ASPSPs.
|
||||
expect(isSessionExpiredResponse(403, body)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT match a bare 401 Unauthorized (app-credential problem, not a dead session)', () => {
|
||||
expect(isSessionExpiredResponse(401, '{"error":"Unauthorized"}')).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT match a non-401/403 status even with a session code in the body', () => {
|
||||
expect(isSessionExpiredResponse(500, CLOSED_SESSION_BODY)).toBe(false)
|
||||
expect(isSessionExpiredResponse(400, '{"error":"ASPSP_ERROR"}')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllTransactionsWithRaw — dead session', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('throws SessionExpiredError on a CLOSED_SESSION 401', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => CLOSED_SESSION_BODY,
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getAllTransactionsWithRaw('acc-1', '2026-01-01', '2026-06-01')
|
||||
).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
})
|
||||
|
||||
const syncRoute = enableBankingExtension.apiRoutes?.find(
|
||||
r => r.method === 'POST' && r.path === '/sync'
|
||||
)
|
||||
|
||||
if (!syncRoute) {
|
||||
throw new Error('POST /sync route not registered on enable-banking extension')
|
||||
}
|
||||
|
||||
function makeContext(connection: Record<string, unknown>, updateSpy: Mock, insertSpy?: Mock): ExtensionContext {
|
||||
// One universal chainable per from() call. Each table only ever terminates on
|
||||
// single() (bank_connections lookup) OR maybeSingle() (sie_imports /
|
||||
// company_members), so a single shared resolver is unambiguous. update()/
|
||||
// insert() record their payloads and return the chain so trailing
|
||||
// .eq()/.select() resolve.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {}
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
chain.gte = vi.fn(() => chain)
|
||||
chain.limit = vi.fn(() => chain)
|
||||
chain.order = vi.fn(() => chain)
|
||||
chain.insert = vi.fn((payload: unknown) => {
|
||||
insertSpy?.(payload)
|
||||
return chain
|
||||
})
|
||||
chain.single = vi.fn().mockResolvedValue({ data: connection, error: null })
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
chain.update = vi.fn((payload: unknown) => {
|
||||
updateSpy(payload)
|
||||
return chain
|
||||
})
|
||||
|
||||
const supabase = {
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
|
||||
},
|
||||
from: vi.fn(() => chain),
|
||||
}
|
||||
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'enable-banking',
|
||||
requestId: 'req_test',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: supabase as any,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
|
||||
storage: {} as never,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
|
||||
services: {} as never,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRequest(): Request {
|
||||
return new Request('http://localhost/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: 'conn-1' }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /sync (enable-banking) — dead session reconnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('flips the connection to expired and returns a reauth-required 409', async () => {
|
||||
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(
|
||||
new SessionExpiredError(401, CLOSED_SESSION_BODY)
|
||||
)
|
||||
|
||||
const updateSpy = vi.fn()
|
||||
const ctx = makeContext(
|
||||
{
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
status: 'active',
|
||||
bank_name: 'Nordea',
|
||||
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }] as StoredAccount[],
|
||||
},
|
||||
updateSpy
|
||||
)
|
||||
|
||||
const res = await syncRoute.handler(makeRequest(), ctx)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.reauth_required).toBe(true)
|
||||
expect(body.code).toBe('SESSION_EXPIRED')
|
||||
expect(body.connection_id).toBe('conn-1')
|
||||
|
||||
// The connection must be marked 'expired' so the UI surfaces the reconnect
|
||||
// affordance instead of looping on the dead session.
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'expired' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const connectRoute = enableBankingExtension.apiRoutes?.find(
|
||||
r => r.method === 'POST' && r.path === '/connect'
|
||||
)
|
||||
|
||||
if (!connectRoute) {
|
||||
throw new Error('POST /connect route not registered on enable-banking extension')
|
||||
}
|
||||
|
||||
describe('POST /connect (enable-banking) — reconnect in place', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reuses the existing row (UPDATE, no INSERT) and keeps it out of the stale-pending sweep', async () => {
|
||||
// startAuthorization() POSTs to /auth — stub it (jwt is already mocked).
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ url: 'https://bank.example/auth', authorization_id: 'auth-123' }),
|
||||
text: async () => '',
|
||||
}))
|
||||
)
|
||||
|
||||
const updateSpy = vi.fn()
|
||||
const insertSpy = vi.fn()
|
||||
const ctx = makeContext(
|
||||
{
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
bank_name: 'Nordea',
|
||||
provider: 'nordea-se',
|
||||
session_id: null, // null → skip the best-effort revoke call
|
||||
status: 'expired',
|
||||
},
|
||||
updateSpy,
|
||||
insertSpy
|
||||
)
|
||||
|
||||
const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: 'conn-1', aspsp_name: 'Nordea', aspsp_country: 'SE' }),
|
||||
})
|
||||
|
||||
const res = await connectRoute.handler(req, ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.connection_id).toBe('conn-1')
|
||||
expect(body.authorization_url).toBe('https://bank.example/auth')
|
||||
|
||||
// In-place: a fresh authorization on the SAME row, never a new INSERT.
|
||||
expect(insertSpy).not.toHaveBeenCalled()
|
||||
|
||||
// The CSRF state is staged on the row FIRST — before startAuthorization, so
|
||||
// before authorization_id even exists — guaranteeing the callback can always
|
||||
// find the row by oauth_state and the bank session can never be orphaned.
|
||||
// It stays 'expired' (not 'pending') so the cron's stale-pending cleanup
|
||||
// can't delete an established connection mid-reconnect.
|
||||
const firstUpdate = updateSpy.mock.calls[0][0]
|
||||
expect(firstUpdate).toMatchObject({
|
||||
oauth_state: expect.any(String),
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
error_message: null,
|
||||
})
|
||||
expect(firstUpdate).not.toHaveProperty('authorization_id')
|
||||
|
||||
// The bank's authorization_id is recorded in a follow-up write (audit only;
|
||||
// the callback never reads it, so a failure here can't break the reconnect).
|
||||
expect(updateSpy.mock.calls[1][0]).toEqual({ authorization_id: 'auth-123' })
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ interface BankConnectionStatusProps {
|
||||
connection: BankConnection
|
||||
onSync: (connectionId: string) => void
|
||||
onDisconnect: (connectionId: string) => void
|
||||
onReconnect?: (bank: { name: string; country: string }) => void
|
||||
onReconnect?: (connection: BankConnection) => void
|
||||
onManageAccounts?: (connectionId: string) => void
|
||||
isSyncing?: boolean
|
||||
}
|
||||
@@ -132,21 +132,18 @@ export function BankConnectionStatus({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnectionExpired && onReconnect && (
|
||||
{(isConnectionExpired || isConnectionError) && onReconnect && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onReconnect({
|
||||
name: connection.bank_name,
|
||||
country: (connection.provider as string)?.split('-').pop()?.toUpperCase() || 'SE',
|
||||
})}
|
||||
onClick={() => onReconnect(connection)}
|
||||
>
|
||||
Förnya anslutning
|
||||
</Button>
|
||||
)}
|
||||
{isConnectionError && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSync(connection.id)}
|
||||
disabled={isSyncing}
|
||||
|
||||
@@ -165,6 +165,52 @@ export default function BankingSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-authorize an existing connection in place — no disconnect required.
|
||||
// Posts to /connect with the existing connection_id so the server reuses the
|
||||
// same row (revoking the dead session, issuing fresh authorization), then
|
||||
// hands off to the bank's consent screen. The OAuth callback drives the row
|
||||
// back through account selection to active.
|
||||
async function handleReconnect(connection: BankConnection) {
|
||||
if (connectingRef.current) return
|
||||
connectingRef.current = true
|
||||
setIsConnecting(true)
|
||||
setConnectingBankName(connection.bank_name)
|
||||
|
||||
try {
|
||||
const country = (connection.provider as string)?.split('-').pop()?.toUpperCase() || 'SE'
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
connection_id: connection.id,
|
||||
aspsp_name: connection.bank_name,
|
||||
aspsp_country: country,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
window.location.href = data.authorization_url
|
||||
} catch (error) {
|
||||
console.error('[enable-banking] Reconnect flow failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
connectionId: connection.id,
|
||||
})
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte förnya anslutningen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
connectingRef.current = false
|
||||
setIsConnecting(false)
|
||||
setConnectingBankName(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncTransactions(connectionId: string) {
|
||||
setSyncingConnectionId(connectionId)
|
||||
|
||||
@@ -214,6 +260,9 @@ export default function BankingSettingsPanel() {
|
||||
variant: 'destructive',
|
||||
})
|
||||
setShowCsvFallback(true)
|
||||
// Refresh so a now-expired connection (e.g. closed PSD2 session) moves
|
||||
// into "Åtgärd krävs" and surfaces the "Förnya anslutning" button.
|
||||
fetchConnections()
|
||||
}
|
||||
|
||||
setSyncingConnectionId(null)
|
||||
@@ -382,7 +431,7 @@ export default function BankingSettingsPanel() {
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
onReconnect={handleConnectBank}
|
||||
onReconnect={handleReconnect}
|
||||
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getASPSPs,
|
||||
deleteSession,
|
||||
isSandboxMode,
|
||||
SessionExpiredError,
|
||||
type ASPSP,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
@@ -102,9 +103,14 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
const companyId = ctx.companyId
|
||||
|
||||
const { aspsp_name, aspsp_country, psu_type: explicitPsuType } = await request.json()
|
||||
const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId } = await request.json()
|
||||
|
||||
if (!aspsp_name || !aspsp_country) {
|
||||
// Reconnect mode: re-authorize an EXISTING connection in place (no
|
||||
// disconnect required). The aspsp identity falls back to the stored row
|
||||
// when the client omits it, so a closed/expired session can be renewed
|
||||
// with one click. A fresh connect still needs the bank name + country.
|
||||
const isReconnect = !!reconnectId
|
||||
if (!isReconnect && (!aspsp_name || !aspsp_country)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
@@ -112,6 +118,43 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
|
||||
try {
|
||||
// For reconnect, load the existing connection up front (company-scoped)
|
||||
// so we can revoke its dead session and reuse its bank identity.
|
||||
let existing:
|
||||
| { id: string; bank_name: string; provider: string; session_id: string | null }
|
||||
| null = null
|
||||
if (isReconnect) {
|
||||
const { data, error: findErr } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, provider, session_id')
|
||||
.eq('id', reconnectId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (findErr || !data) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
existing = data
|
||||
}
|
||||
|
||||
// Resolve the aspsp identity. For a reconnect the bank is already
|
||||
// known, so derive it authoritatively from the stored row and IGNORE
|
||||
// any client-supplied aspsp_name/aspsp_country — the client
|
||||
// (BankSyncNowButton) derives the country by string-splitting the
|
||||
// provider slug, and trusting that back is needless attack surface
|
||||
// (compliance: ASVS V8.2.1/V4.5). A fresh connect has no stored row,
|
||||
// so it uses the client values (already required+validated above).
|
||||
// The provider slug ends with the country code, e.g. "nordea-se".
|
||||
const resolvedAspspName = isReconnect ? existing?.bank_name : aspsp_name
|
||||
const resolvedAspspCountry = isReconnect
|
||||
? existing?.provider?.split('-').pop()?.toUpperCase() || 'SE'
|
||||
: aspsp_country
|
||||
if (!resolvedAspspName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Detect PSU type: explicit override > company entity_type > default 'business'
|
||||
let psuType: 'personal' | 'business' = 'business'
|
||||
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
|
||||
@@ -129,49 +172,53 @@ export const enableBankingExtension: Extension = {
|
||||
|
||||
log.info('[enable-banking] Starting bank connection', {
|
||||
user_id: user.id,
|
||||
bank: aspsp_name,
|
||||
country: aspsp_country,
|
||||
bank: resolvedAspspName,
|
||||
country: resolvedAspspCountry,
|
||||
psu_type: psuType,
|
||||
reconnect: isReconnect,
|
||||
})
|
||||
|
||||
// Reject if there's already a recent pending connection for this user+bank
|
||||
// to prevent double-click race conditions that confuse the bank's consent flow
|
||||
const { data: recentPending } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_name', aspsp_name)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
// to prevent double-click race conditions that confuse the bank's consent
|
||||
// flow. Skipped for reconnect — that deliberately re-authorizes a known row.
|
||||
if (!isReconnect) {
|
||||
const { data: recentPending } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_name', resolvedAspspName)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (recentPending) {
|
||||
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
|
||||
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
|
||||
if (recentPending) {
|
||||
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
|
||||
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
|
||||
|
||||
if (pendingAge < STALE_THRESHOLD_MS) {
|
||||
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
|
||||
existing_id: recentPending.id,
|
||||
if (pendingAge < STALE_THRESHOLD_MS) {
|
||||
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
|
||||
existing_id: recentPending.id,
|
||||
age_ms: pendingAge,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up stale pending connections (older than threshold)
|
||||
log.info('[enable-banking] Cleaning up stale pending connections', {
|
||||
stale_id: recentPending.id,
|
||||
age_ms: pendingAge,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_name', resolvedAspspName)
|
||||
.eq('status', 'pending')
|
||||
}
|
||||
|
||||
// Clean up stale pending connections (older than threshold)
|
||||
log.info('[enable-banking] Cleaning up stale pending connections', {
|
||||
stale_id: recentPending.id,
|
||||
age_ms: pendingAge,
|
||||
})
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_name', aspsp_name)
|
||||
.eq('status', 'pending')
|
||||
}
|
||||
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
||||
@@ -179,9 +226,94 @@ export const enableBankingExtension: Extension = {
|
||||
// Generate cryptographic state token for CSRF protection
|
||||
const oauthState = crypto.randomUUID()
|
||||
|
||||
if (isReconnect && existing) {
|
||||
// Persist the CSRF state to the existing row BEFORE asking the bank
|
||||
// to start an authorization. The OAuth callback locates this row only
|
||||
// by oauth_state, so writing it first guarantees that once the bank
|
||||
// holds a session bound to this state a matching row already exists.
|
||||
// If startAuthorization ran first and this UPDATE then failed, the
|
||||
// bank session would be orphaned with no row to complete it.
|
||||
//
|
||||
// Reuse the SAME row: the callback drives it back to
|
||||
// pending_selection → active, so existing transactions and the
|
||||
// cash_accounts mirror stay linked. Deliberately keep status
|
||||
// 'expired' (NOT 'pending') during the round-trip: this row's
|
||||
// created_at is old and the cron deletes stale 'pending' rows after
|
||||
// 1h — a reconnect must not be eligible for that. Staying 'expired'
|
||||
// also keeps it visible in "Åtgärd krävs" so an abandoned reconnect
|
||||
// is recoverable. The callback's oauth_state lookup accepts 'expired'.
|
||||
const { error: stateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
oauth_state: oauthState,
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
error_message: null,
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (stateError) {
|
||||
log.error('[enable-banking] Database error staging reconnect state', {
|
||||
errorMessage: stateError.message,
|
||||
errorCode: stateError.code,
|
||||
connection_id: existing.id,
|
||||
user_id: user.id,
|
||||
})
|
||||
throw new Error(`Failed to update connection: ${stateError.message}`)
|
||||
}
|
||||
|
||||
// Best-effort revoke the dead consent at Enable Banking. A
|
||||
// closed/expired session is often already gone, so a failure here is
|
||||
// expected and non-fatal — the new authorization supersedes it.
|
||||
// Logged at WARN so a systematic revoke failure is visible to
|
||||
// monitoring (compliance: ASVS V16 / ISO 27001 A.8.15).
|
||||
if (existing.session_id) {
|
||||
try {
|
||||
await deleteSession(existing.session_id)
|
||||
} catch (revokeError) {
|
||||
log.warn('[enable-banking] Old session revoke skipped (likely already expired)', {
|
||||
message: revokeError instanceof Error ? revokeError.message : String(revokeError),
|
||||
connection_id: existing.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
resolvedAspspName,
|
||||
resolvedAspspCountry,
|
||||
redirectUrl,
|
||||
oauthState,
|
||||
psuType
|
||||
)
|
||||
|
||||
// Record the bank's authorization_id for audit/traceability. The
|
||||
// callback matches on oauth_state alone (already persisted above), so
|
||||
// a failure here cannot orphan the flow — log and continue.
|
||||
const { error: authIdError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ authorization_id })
|
||||
.eq('id', existing.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (authIdError) {
|
||||
log.warn('[enable-banking] Could not persist authorization_id on reconnect (non-fatal)', {
|
||||
errorMessage: authIdError.message,
|
||||
connection_id: existing.id,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: existing.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
}
|
||||
|
||||
// Fresh connect: create the bank authorization, then persist the new
|
||||
// row carrying its oauth_state so the callback can find it.
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
resolvedAspspName,
|
||||
resolvedAspspCountry,
|
||||
redirectUrl,
|
||||
oauthState,
|
||||
psuType
|
||||
@@ -192,8 +324,8 @@ export const enableBankingExtension: Extension = {
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
provider: `${resolvedAspspName.toLowerCase().replace(/\s+/g, '-')}-${resolvedAspspCountry.toLowerCase()}`,
|
||||
bank_name: resolvedAspspName,
|
||||
authorization_id,
|
||||
oauth_state: oauthState,
|
||||
status: 'pending',
|
||||
@@ -207,7 +339,7 @@ export const enableBankingExtension: Extension = {
|
||||
errorCode: error.code,
|
||||
errorDetails: error.details,
|
||||
user_id: user.id,
|
||||
bank: aspsp_name,
|
||||
bank: resolvedAspspName,
|
||||
})
|
||||
throw new Error(`Failed to store connection: ${error.message}`)
|
||||
}
|
||||
@@ -420,6 +552,31 @@ export const enableBankingExtension: Extension = {
|
||||
connectionStatus: connection.status,
|
||||
bankName: connection.bank_name,
|
||||
})
|
||||
|
||||
// A dead PSD2 session (closed/expired/invalid consent) can't be fixed
|
||||
// by retrying — the user must re-authorize. Flip the connection to
|
||||
// 'expired' so the UI surfaces the reconnect affordance, and tell the
|
||||
// client re-auth is required (reauth_required) so it can offer a
|
||||
// one-click "Förnya anslutning" instead of a dead-end error. No
|
||||
// disconnect needed: /connect reconnects this same connection in place.
|
||||
if (error instanceof SessionExpiredError) {
|
||||
const reauthMessage = 'Bankanslutningen har löpt ut. Förnya anslutningen för att fortsätta synka.'
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'expired', error_message: reauthMessage })
|
||||
.eq('id', connection.id)
|
||||
.eq('company_id', companyId)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: reauthMessage,
|
||||
code: 'SESSION_EXPIRED',
|
||||
reauth_required: true,
|
||||
connection_id: connection.id,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -181,6 +181,58 @@ class TransactionsFetchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized signatures (uppercase, non-alphanumerics stripped) of the
|
||||
* responses Enable Banking — or the upstream ASPSP via Enable Banking's
|
||||
* envelope — returns when the PSD2 session can no longer be used: the consent
|
||||
* was closed, expired, or invalidated bank-side. Spelling and casing vary by
|
||||
* bank (CLOSED_SESSION, EXPIRED_SESSION, SESSION_EXPIRED / session_expired,
|
||||
* INVALID_SESSION, SESSION_NOT_FOUND, WRONG_SESSION_STATUS, and the plain
|
||||
* "Session is closed" message), so we match the whole family. A dead session
|
||||
* is unrecoverable by retrying — the user must re-authorize.
|
||||
*/
|
||||
const SESSION_DEAD_NEEDLES = [
|
||||
'CLOSEDSESSION', // CLOSED_SESSION
|
||||
'SESSIONCLOSED', // "session closed"
|
||||
'SESSIONISCLOSED', // "Session is closed"
|
||||
'SESSIONEXPIRED', // SESSION_EXPIRED / session_expired
|
||||
'EXPIREDSESSION', // EXPIRED_SESSION
|
||||
'INVALIDSESSION', // INVALID_SESSION
|
||||
'SESSIONNOTFOUND', // SESSION_NOT_FOUND
|
||||
'WRONGSESSIONSTATUS', // WRONG_SESSION_STATUS
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Whether a failed transactions response signals a dead PSD2 session (vs. a
|
||||
* transient error or a config-level auth failure). Only 401/403 with a
|
||||
* session-expiry signal in the body counts — a bare 401 "Unauthorized" is an
|
||||
* app-credential problem, not a closed consent, and must NOT be misread as
|
||||
* "reconnect the bank". The match is deterministic: normalize the body and
|
||||
* test for any known session-dead needle.
|
||||
*/
|
||||
export function isSessionExpiredResponse(status: number, body: string): boolean {
|
||||
if (status !== 401 && status !== 403) return false
|
||||
const normalized = body.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
return SESSION_DEAD_NEEDLES.some(needle => normalized.includes(needle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a transactions fetch fails because the PSD2 session is dead
|
||||
* (closed/expired/invalid). Distinct from TransactionsFetchError so the sync
|
||||
* handler can flip the connection to 'expired' and prompt re-authorization
|
||||
* instead of surfacing a raw error the user can't act on. Carries the status
|
||||
* and raw body for logging. See isSessionExpiredResponse for the codes covered.
|
||||
*/
|
||||
export class SessionExpiredError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: string
|
||||
) {
|
||||
super(`Bank session expired (${status}): ${body}`)
|
||||
this.name = 'SessionExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
// API Helper
|
||||
|
||||
async function authenticatedFetch(
|
||||
@@ -521,6 +573,9 @@ export async function getAccountTransactions(
|
||||
strategy,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
})
|
||||
if (isSessionExpiredResponse(response.status, body)) {
|
||||
throw new SessionExpiredError(response.status, body)
|
||||
}
|
||||
throw new TransactionsFetchError(response.status, body)
|
||||
}
|
||||
|
||||
@@ -771,6 +826,9 @@ export async function getAllTransactionsWithRaw(
|
||||
page,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
})
|
||||
if (isSessionExpiredResponse(response.status, body)) {
|
||||
throw new SessionExpiredError(response.status, body)
|
||||
}
|
||||
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user