* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account Enable Banking has no top-level `bban` key on AccountIdentification: a Swedish BBAN (clearing + account number) arrives as `other.identification` with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed `bban?: string` and read `.bban`, so the value was always undefined: no connected account ever carried its clearing + account number, and domestic counterparty accounts on transactions were dropped. Type the identifiers per the OpenAPI spec, add extractBban() and pickAccountIdentifier(), read counterparty identifiers through the scheme list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on StoredAccount from the OAuth callback. The external_id dedup scope stays IBAN-then-uid and is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): named payee accounts on cash_accounts with a default per currency A company had exactly one set of payment instructions per invoice currency (company_settings.invoice_payment_accounts), picked by currency alone. A second SEK bank account, or a second bankgiro number, had nowhere to live. cash_accounts is already the per-company bank-account entity. Migration 20260903150000 adds the payee fields (bankgiro, plusgiro, clearing + account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a small invoice_payee_defaults table (one default account per currency; one account may be the default for several currencies, a SEK account with an IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites the legacy map and the SEK bank columns from the default accounts. Every existing reader (PDF, email, reminders, v1, MCP) keeps working; the three writers that only touched legacy columns (PUT /api/settings, v1 settings, MCP update_company_settings) now write through to the default account, so what an agent sets is what the PDF prints. Peppol PaymentMeans is built from the resolver instead of the raw legacy column. bg_pg is dropped (never read or written; NULL on every prod and staging row). Backfill lands only on existing cash accounts (primary, IBAN match, or the only enabled account in the currency). Entries with no target stay in the map as the resolver fallback and get an attach action in settings. New: POST /api/cash-accounts (manual bank account on the next free 19xx), PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT /api/cash-accounts/payee-defaults. Settings page rewritten as an account list with per-currency defaults. Behandlingshistorik and the full archive cover the new table and columns. Verified on staging: migration applied (11 defaults landed), mirror trigger observed rewriting company_settings from a payee edit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): choose which bank account an invoice is paid to, frozen at issue Migration 20260903160000 adds invoices.payment_cash_account_id (FK to cash_accounts, SET NULL) and invoices.payment_details, the payee fields frozen when the account is chosen and refreshed at issue. Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount / assertInvoicePaymentAccountForRender take an optional override, and hasRequiredInvoicePaymentAccount reads it from the invoice row, so every surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol, recurring, staged MCP send) prints the frozen payee when one exists and the company default per currency otherwise. Invoices that never chose an account behave exactly as before. Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send, recurring, MCP send and mark-sent) refresh the snapshot from the account as it is at issue; a chosen account that is disabled, un-flagged or unusable for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID. Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice accept payment_cash_account_id and validate it against the company's payee accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the original's payee; copies carry the choice; preview-pdf renders the chosen account. The editor shows "Betalas till" under the currency when the company has two or more usable payee accounts for that currency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): book manual payments on the invoice's chosen bank account Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the booking dialog's proposed lines debited 1930 regardless of which bank account the invoice asked to be paid to. They now resolve the chosen payee account's ledger account (resolveInvoiceSettlementAccount) and fall back to 1930 only when no account was chosen or the row is gone. Bank-transaction matching keeps debiting the account the money landed on and does not filter by the chosen account; between equal-confidence candidates it prefers the invoice that asked to be paid to the landing account. Scores are untouched, so nothing new auto-matches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(invoices): keep the payload-size and phantom-column ceilings after the payee work Shorten the new gnubok_create_invoice argument description (tools/list payload was 29 bytes over the 60 kB budget), inline the cash-account payee UPDATE/INSERT payloads and the settings select strings as literals so the phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE instead of a hand-rolled copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK) Review findings from CodeRabbit, Superagent, the Swedish accounting review and three skeptic passes, resolved in one batch: Schema (both migrations are unshipped and edited in place): - cash_accounts.payee_iban: the printed IBAN is its own column. iban stays the bank identity written by every sync and used to re-pair on reconnect, so a sync can no longer rewrite an invoice instruction or resurrect a cleared IBAN. The backfill copies each currency entry verbatim onto the target account (IBAN match first, then primary), so every invoice keeps printing exactly what it printed before; the bank IBAN is never pushed onto invoices that did not carry one. - Payee columns are owner/admin-only at the database (BEFORE trigger, service role exempt): cash_accounts is member-writable for bank sync, and the SECURITY DEFINER mirror would otherwise have let a member rewrite where customers pay. - Revoking an account as payee or disabling it drops its defaults; deleting a default drops that currency from the map and clears the legacy SEK columns (an admin saying "nothing to print" must not keep printing a closed account). The mirror leaves the legacy SEK columns alone when the map has no SEK entry, so legacy-only companies are never wiped by a mirror run for another currency. - Audit and mirror triggers fire on the same column set; anon and authenticated can no longer execute the trigger-only definer functions. - invoices.payment_cash_account_id is a composite same-company FK with SET NULL scoped to the account column. Code: - Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now also requires enabled, payee-flagged and usable for the currency), resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which also refuses disabled rows and logs every fallback to 1930). - createManualBankAccount excludes every ledger slot any row already holds (findFreeLedgerAccount treats a manual holder as free; this path inserts). - The legacy settings writers (PUT /api/settings, v1, MCP) write through to the account BEFORE updating company_settings and fail the request on error; the account is written before it is adopted as default so the mirror never sees an empty payee. - snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid projections carry the payee columns; v1 create validates the payee before the dry-run return and echoes it in the preview. - pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and non-account schemes (card PANs) are never persisted. - Editor shows the payee select for a single usable account with no default; the booking dialog waits for cash accounts before proposing lines; a failed default write no longer hides a created account. - Behandlingshistorik names the account on created/deleted defaults. - Regenerated skills/accounted-api; MCP argument description trimmed under the tools/list payload ceiling. Declined: clearing legacy columns via a forward migration (the mirror now does it on delete); Swedish review's "show the debit account in the mark-paid UI" (the booking dialog already proposes and lets the user edit the debit line); manual ledger collision (UNIQUE exists, and the create path now rejects it with a clear error); Peppol aligning to the PDF value for companies whose legacy column had drifted from the map (the PDF is the customer-facing document; both now agree). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves record fields per expression, so the combined condition failed with "record new has no field invoice_payee" whenever a default row changed, which took down every pg-real case on the payee tables. The revoke/disable check now sits inside its own TG_TABLE_NAME branch. The MCP settings executor test mocks the payee write-through like the settings route test already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload Cycle 3 of /resolve-pr on #2233. Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's invoice_payee_defaults rows whenever cash_accounts.enabled flipped to false, and enabled is member-writable (the bank picker's "Synkas ej"), so a member could undo an admin's payee decision. The trigger now drops defaults only on the admin-only invoice_payee true -> false revoke; the mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out of the pick lists and the send gate already refuses an invoice that chose one. Applied to staging as the same function + trigger definition and probed inside a rolled-back block: disable keeps the default and the mirrored bankgiro, revoke clears both. pg-real: the admin-guard test ran three expectations inside one withUserContext transaction; the first raise aborted it and the next statement failed with "current transaction is aborted". One transaction per expectation now, and the member case also flips enabled to prove the column stays member-level. Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa and the 1911-1919 tills. A customer pays to a giro or bank account, so isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH route now require BAS 1920-1999; tests cover 1910 and 1919. Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014 tokens after main merged #2166 and #2163 alongside this branch. The ceiling is not bumped and no read on this surface is a demotion candidate, so gnubok_create_invoice drops payment_cash_account_id; agent-created invoices print the per-currency default and v1 REST plus the editor keep the field. Recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration origin/main merged 20260903160000_kpi_monthly_include_reversed_originals while this branch held the same version; identical versions abort the Supabase apply. Staging's schema_migrations row was moved to the new version with the file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet Cycle 4 of /resolve-pr on #2233, on Emil's go. Swedish review: the 1920-1999 payee rule lived only in the routes. The cash_accounts_payee_admin_only trigger now also refuses invoice_payee on any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes it, and the backfill only targets giro/bank rows, so a company whose single enabled cash_accounts row is a Stripe clearing account keeps its legacy bankgiro in company_settings instead of landing it on 1686. pg test covers insert and update on 1686 and 1910; the function was applied to staging and probed. Typecheck ratchet: main is red from two merges that landed with failing Checks, and every branch that syncs it inherits the errors. - #2242 added POST(req) calls to the fiscal-periods route test without the route params argument withRouteContext handlers take (25 errors in the file, baseline 23). All 25 calls now pass createMockRouteParams({}). - #2247 made SyncResult.requestedFromDate and historyNarrowed required; the 13 mockedSync results in the enable-banking accounts-route test lacked them. They now carry a fixed date and historyNarrowed: false. Both files' tests pass unchanged in behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion origin/main merged 20260903183000_party_promotion while this branch held the same version. Staging's schema_migrations row must follow (pending: the Supabase MCP was disconnected at the time of this commit). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
955 lines
43 KiB
TypeScript
955 lines
43 KiB
TypeScript
import { randomBytes } from 'node:crypto'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { NextResponse, after } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { createSession, extractBban, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
|
|
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import {
|
|
upsertFromPsd2,
|
|
resolvePsd2LedgerAccount,
|
|
defaultLedgerForCurrency,
|
|
normalizeIban,
|
|
} from '@/lib/cash-accounts/service'
|
|
import {
|
|
fanOutSessionRenewal,
|
|
fetchCrossCompanyAccountContext,
|
|
} from '@/extensions/general/enable-banking/lib/session-sharing'
|
|
import { supersedeSiblingConnections } from '@/extensions/general/enable-banking/lib/supersede'
|
|
import { getBankConnectionErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
|
|
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
|
|
import {
|
|
requireFlowInitiator,
|
|
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
} from '@/lib/auth/oauth-flow-binding'
|
|
|
|
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
|
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
|
|
// load so registerEventLogHandler() has subscribed before the first emit();
|
|
// otherwise the audit row is silently dropped on a cold instance where this
|
|
// redirect route is the first event-emitting code path to execute.
|
|
ensureInitialized()
|
|
|
|
// Structured logger for audit-trail failures (ISO 27001 A.8.15): a failed
|
|
// audit-event emission must be visible to log-based alerting, not just a raw
|
|
// console line. The stable message below is what monitoring keys on.
|
|
const log = createLogger('enable-banking/callback')
|
|
const AUDIT_EMIT_FAILED = 'audit event emit failed'
|
|
|
|
type ServiceClient = Awaited<ReturnType<typeof createServiceClient>>
|
|
|
|
interface PendingConnection {
|
|
id: string
|
|
user_id: string
|
|
company_id: string
|
|
bank_name: string | null
|
|
status: string
|
|
/**
|
|
* The session being replaced, captured before the update overwrites it, so a
|
|
* renewal can be carried to sibling companies sharing it (see
|
|
* 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.
|
|
// User-facing, so Swedish (the raw upstream error is in the server log).
|
|
const FINALIZE_FAILED_MESSAGE =
|
|
'Anslutningen kunde inte slutföras. Försök igen om en stund.'
|
|
|
|
/**
|
|
* GET /api/extensions/enable-banking/callback
|
|
*
|
|
* OAuth callback for Enable Banking PSD2 authorization.
|
|
* Must be a real Next.js route (not extension handler) because
|
|
* banks redirect to this URL directly.
|
|
*
|
|
* Fast outcomes (bank denial, bad params, unknown state) respond with a
|
|
* classic 307. The success path instead streams an interim "Slutför
|
|
* bankanslutningen" page while the slow work runs (session exchange with
|
|
* Enable Banking, cash-account mirroring), then streams a client-side
|
|
* redirect: without this the user stares at a blank tab for several seconds,
|
|
* which reads as a failed connection and provokes duplicate retries.
|
|
*/
|
|
export async function GET(request: Request) {
|
|
const { searchParams } = new URL(request.url)
|
|
|
|
const code = searchParams.get('code')
|
|
const state = searchParams.get('state') // Cryptographic oauth_state token
|
|
const error = searchParams.get('error')
|
|
const errorDescription = searchParams.get('error_description')
|
|
// Present in connector mode only: the hosted callback echoes the signed
|
|
// connector state back to this instance so createSession can bind the proxy's
|
|
// /sessions exchange to the pending ledger row. Null on the direct path.
|
|
const connectorState = searchParams.get('connector_state')
|
|
|
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
|
|
// Connector branch: a self-hosted instance started this authorization through
|
|
// the /api/connect/bank proxy, which replaced the upstream state with a
|
|
// signed connector state carrying the instance's own return URL. We never
|
|
// create a session here (the instance does, through the proxy): we just
|
|
// bounce the browser back to the instance with the code + its original
|
|
// state, so no per-instance redirect URI has to be registered with EB.
|
|
if (isConnectorState(state)) {
|
|
const verified = verifyConnectorState(state as string)
|
|
if (!verified.ok || verified.payload.svc !== 'bank') {
|
|
return NextResponse.redirect(`${baseUrl}/?connector_error=${encodeURIComponent(verified.ok ? 'wrong_service' : verified.reason)}`)
|
|
}
|
|
const ret = new URL(verified.payload.ret)
|
|
if (error) ret.searchParams.set('error', error)
|
|
if (errorDescription) ret.searchParams.set('error_description', errorDescription)
|
|
if (code) ret.searchParams.set('code', code)
|
|
if (verified.payload.st) ret.searchParams.set('state', verified.payload.st)
|
|
// Echo the signed connector state so the instance can present it back to
|
|
// the proxy's POST /sessions (which finds the pending ledger row by it).
|
|
ret.searchParams.set('connector_state', state as string)
|
|
return NextResponse.redirect(ret.toString())
|
|
}
|
|
|
|
if (error) {
|
|
// Swedish user-facing message carrying the underlying provider error; the
|
|
// raw code/description stays in the log lines and the audit event below.
|
|
// Previously the raw provider text was passed through verbatim, which
|
|
// gave a stuck user nothing to act on (issue #1716).
|
|
const userMessage = getBankConnectionErrorMessage(error, errorDescription)
|
|
// access_denied is the user cancelling at the bank — an expected outcome,
|
|
// not a runtime error. Only bank-side failures stay at error level.
|
|
const isUserCancel =
|
|
error === 'access_denied' || /cancelled by user/i.test(errorDescription ?? '')
|
|
const logDenied = isUserCancel ? console.warn : console.error
|
|
logDenied('[enable-banking] Bank authorization denied', {
|
|
error,
|
|
error_description: errorDescription,
|
|
has_state: !!state,
|
|
})
|
|
|
|
// Clean up the pending bank_connections row so it doesn't accumulate
|
|
if (state) {
|
|
try {
|
|
const supabase = await createServiceClient()
|
|
|
|
// Fetch connection details for logging before updating. Match by
|
|
// oauth_state across pending/expired/error so an in-place reconnect
|
|
// (which stays 'expired' during the round-trip) is also handled.
|
|
const { data: pendingConn } = await supabase
|
|
.from('bank_connections')
|
|
.select('id, user_id, company_id, bank_name, psu_type, status')
|
|
.eq('oauth_state', state)
|
|
.in('status', ['pending', 'expired', 'error'])
|
|
.single()
|
|
|
|
if (pendingConn) {
|
|
logDenied('[enable-banking] Authorization denied details', {
|
|
connection_id: pendingConn.id,
|
|
user_id: pendingConn.user_id,
|
|
bank_name: pendingConn.bank_name,
|
|
error_code: error,
|
|
error_description: errorDescription,
|
|
})
|
|
|
|
if (pendingConn.status === 'pending') {
|
|
// Fresh connect that never became a connection: delete the row
|
|
// instead of parking it in 'error'. A parked row renders forever
|
|
// as an "Åtgärd krävs" card, so a failed attempt followed by a
|
|
// successful retry showed up as two connections to the same bank.
|
|
// The ?bank_error banner below is the actual failure feedback.
|
|
await supabase
|
|
.from('bank_connections')
|
|
.delete()
|
|
.eq('id', pendingConn.id)
|
|
.eq('status', 'pending')
|
|
} else {
|
|
// Reconnect of an established connection: keep the row (it holds
|
|
// accounts/transactions history) and surface the failure on it.
|
|
// If the bank reports a session-expiry during authorization
|
|
// itself, mark it 'expired' (not generic 'error') so the settings
|
|
// panel surfaces the reconnect button rather than a dead-end
|
|
// error state.
|
|
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
|
|
`${error} ${errorDescription ?? ''}`
|
|
)
|
|
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: userMessage, oauth_state: null })
|
|
.eq('id', pendingConn.id)
|
|
}
|
|
|
|
// Durable audit trail for the failed attempt (issue #1716): the
|
|
// fresh-connect row was just deleted and console logs expire, so
|
|
// event_log is the only place support can later see which attempt
|
|
// failed with which provider error.
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'bank_connection.consent_denied',
|
|
payload: {
|
|
connectionId: pendingConn.id,
|
|
bankName: pendingConn.bank_name ?? null,
|
|
psuType: pendingConn.psu_type ?? null,
|
|
errorCode: error,
|
|
errorDescription: errorDescription ?? null,
|
|
priorStatus: pendingConn.status,
|
|
userId: pendingConn.user_id,
|
|
companyId: pendingConn.company_id,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
|
|
eventType: 'bank_connection.consent_denied',
|
|
connectionId: pendingConn.id,
|
|
})
|
|
}
|
|
|
|
// Include bank name, error code, and psu_type in the redirect so the
|
|
// UI can render targeted guidance (e.g. PSU-type retry on
|
|
// access_denied, or the Handelsbanken corporate fullmakt steps on
|
|
// server_error for a business connect).
|
|
const params = new URLSearchParams({
|
|
bank_error: userMessage,
|
|
...(pendingConn.bank_name ? { bank_name: pendingConn.bank_name } : {}),
|
|
bank_error_code: error,
|
|
...(pendingConn.psu_type ? { psu_type: pendingConn.psu_type } : {}),
|
|
})
|
|
return NextResponse.redirect(`${baseUrl}/settings/banking?${params.toString()}`)
|
|
}
|
|
} catch (cleanupError) {
|
|
console.error('[enable-banking] Failed to clean up pending bank connection:', cleanupError)
|
|
}
|
|
}
|
|
|
|
return NextResponse.redirect(
|
|
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(userMessage)}`
|
|
)
|
|
}
|
|
|
|
if (!code || !state) {
|
|
return NextResponse.redirect(
|
|
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(getBankConnectionErrorMessage('missing_parameters'))}`
|
|
)
|
|
}
|
|
|
|
// Validate authorization code format
|
|
const codePattern = /^[a-zA-Z0-9._~+\/-]{8,2048}$/
|
|
if (!codePattern.test(code)) {
|
|
return NextResponse.redirect(
|
|
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(getBankConnectionErrorMessage('invalid_code_format'))}`
|
|
)
|
|
}
|
|
|
|
const supabase = await createServiceClient()
|
|
|
|
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
|
|
// oauth_state is a single-use random token cleared after use, so it uniquely
|
|
// identifies the row regardless of status. Accept 'expired'/'error' too: an
|
|
// in-place reconnect keeps the row in 'expired' during the round-trip (so
|
|
// the nightly stale-'pending' cleanup can't delete an established row).
|
|
// This lookup is fast, so it runs BEFORE the streamed response: an unknown
|
|
// 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, accounts_data')
|
|
.eq('oauth_state', state)
|
|
.in('status', ['pending', 'expired', 'error'])
|
|
.single()
|
|
|
|
if (findError || !pendingConnection) {
|
|
console.error('[enable-banking] No pending connection for oauth_state', {
|
|
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
|
|
state,
|
|
hasCode: !!code,
|
|
})
|
|
return NextResponse.redirect(
|
|
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(getBankConnectionErrorMessage('invalid_state'))}`
|
|
)
|
|
}
|
|
|
|
// The state token proves this callback belongs to a flow we started; it
|
|
// says nothing about WHO is completing it. Bind the completion to the
|
|
// initiator's own cookie session before any finalize work: otherwise a
|
|
// victim lured into approving a consent someone else started would have
|
|
// their bank accounts attached to that someone's company. The connector
|
|
// branch above is exempt on purpose (server-to-server, HMAC-verified).
|
|
const initiator = await requireFlowInitiator(request, pendingConnection.user_id, {
|
|
flow: 'enable-banking.callback',
|
|
})
|
|
if (!initiator.ok) {
|
|
if (initiator.reason === 'no_session') {
|
|
// Session expired mid-flow: sign in and the callback re-runs with the
|
|
// same code + state. Nothing on the row changes.
|
|
return initiator.response
|
|
}
|
|
// A different user completed it. Refuse without exchanging the code and
|
|
// without touching the row: it keeps waiting for its initiator and the
|
|
// stale-pending cleanup reaps it if nobody comes back.
|
|
const params = new URLSearchParams({
|
|
bank_error: FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
...(pendingConnection.bank_name ? { bank_name: pendingConnection.bank_name } : {}),
|
|
})
|
|
return NextResponse.redirect(`${baseUrl}/settings/banking?${params.toString()}`)
|
|
}
|
|
|
|
// Kick the finalize work off eagerly, decoupled from the response stream:
|
|
// if the user closes the tab mid-stream, the stream is cancelled but this
|
|
// promise keeps running, so the session persistence, cash-account mirror
|
|
// and consent_granted audit emit are not lost (ASVS V16). Never rejects:
|
|
// failures resolve to the cleanup redirect target.
|
|
const finalizePromise = (async (): Promise<string> => {
|
|
try {
|
|
return await finalizeConnection(supabase, pendingConnection, code, connectorState)
|
|
} catch (finalizeError) {
|
|
const reason =
|
|
finalizeError instanceof Error ? finalizeError.message : String(finalizeError)
|
|
console.error('[enable-banking] Callback error', {
|
|
message: reason,
|
|
stack: finalizeError instanceof Error ? finalizeError.stack : undefined,
|
|
name: finalizeError instanceof Error ? finalizeError.name : undefined,
|
|
state,
|
|
connectionId: pendingConnection.id,
|
|
})
|
|
// Durable audit trail (issue #1716): the fresh-connect row is deleted by
|
|
// the cleanup below and console logs expire, so event_log is the only
|
|
// place support can later see that this attempt failed and why.
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'bank_connection.finalize_failed',
|
|
payload: {
|
|
connectionId: pendingConnection.id,
|
|
bankName: pendingConnection.bank_name ?? null,
|
|
reason,
|
|
priorStatus: pendingConnection.status,
|
|
userId: pendingConnection.user_id,
|
|
companyId: pendingConnection.company_id,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
|
|
eventType: 'bank_connection.finalize_failed',
|
|
connectionId: pendingConnection.id,
|
|
})
|
|
}
|
|
return cleanupFailedFinalize(supabase, pendingConnection)
|
|
}
|
|
})()
|
|
|
|
// Keep the serverless function alive until the finalize work settles even
|
|
// if the client disconnects and the platform considers the response done.
|
|
try {
|
|
after(() => finalizePromise.then(() => undefined))
|
|
} catch {
|
|
// Outside a request scope (unit tests, plain node server): the stream's
|
|
// own await below still drives the promise to completion.
|
|
}
|
|
|
|
// Per-request CSP nonce for the two inline scripts on the finalize page
|
|
// (ASVS V3.3): mirrors the mcp-oauth consent page. The global next.config
|
|
// CSP also applies; the intersection means inline scripts on THIS response
|
|
// must carry the nonce.
|
|
const cspNonce = randomBytes(16).toString('base64')
|
|
const csp = [
|
|
"default-src 'none'",
|
|
`script-src 'nonce-${cspNonce}'`,
|
|
"style-src 'unsafe-inline'",
|
|
"base-uri 'none'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
].join('; ')
|
|
|
|
// Stream: flush the branded "Slutför bankanslutningen" shell immediately,
|
|
// await the finalize work, then stream a client-side redirect to the
|
|
// outcome URL. The user sees progress from the first byte instead of a
|
|
// blank tab.
|
|
const encoder = new TextEncoder()
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
controller.enqueue(encoder.encode(renderFinalizeShell(pendingConnection.bank_name, cspNonce)))
|
|
const targetPath = await finalizePromise
|
|
try {
|
|
controller.enqueue(encoder.encode(renderFinalizeRedirect(`${baseUrl}${targetPath}`, cspNonce)))
|
|
controller.close()
|
|
} catch {
|
|
// Stream already cancelled (client closed the tab). The finalize
|
|
// work above completed regardless; there is just no one to redirect.
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Content-Security-Policy': csp,
|
|
// The body carries a one-time OAuth outcome: never cache, never buffer
|
|
// (X-Accel-Buffering opts out of proxy buffering so the shell chunk
|
|
// actually reaches the browser before the work finishes).
|
|
'Cache-Control': 'no-store',
|
|
'X-Accel-Buffering': 'no',
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The slow part of the callback: exchange the authorization code for a PSD2
|
|
* session, persist the account metadata, mirror accounts into cash_accounts,
|
|
* and emit the audit event. Returns the app-relative redirect target.
|
|
* Extracted so the route can run it behind the streamed progress page.
|
|
*/
|
|
async function finalizeConnection(
|
|
supabase: ServiceClient,
|
|
pendingConnection: PendingConnection,
|
|
code: string,
|
|
connectorState: string | null,
|
|
): Promise<string> {
|
|
const userId = pendingConnection.user_id
|
|
|
|
console.log('[enable-banking] Exchanging code for session', {
|
|
connectionId: pendingConnection.id,
|
|
userId,
|
|
codeLength: code.length,
|
|
})
|
|
|
|
const sessionData = await createSession(code, connectorState ?? undefined)
|
|
const { session_id, accounts, access } = sessionData
|
|
const consentExpiresAt = access.valid_until
|
|
|
|
console.log('[enable-banking] Session created successfully', {
|
|
connectionId: pendingConnection.id,
|
|
sessionId: '[REDACTED]',
|
|
accountCount: accounts.length,
|
|
consentExpiresAt,
|
|
})
|
|
|
|
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
|
|
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
|
|
// currency). Balances are bank account financial data: we don't fetch
|
|
// 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.
|
|
// 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 }>()
|
|
// The user's earlier sync choice per account ("Synkas ej" = enabled:false)
|
|
// must survive a renewal: a deselected private card that comes back
|
|
// pre-checked lands its transactions in the company's books the moment the
|
|
// user saves the picker with defaults. Matched by uid first (exact resource
|
|
// identity; one session can list the same IBAN twice, e.g. one resource per
|
|
// balance type), then by IBAN for ASPSPs that mint new uids on re-auth.
|
|
const priorEnabledByIban = new Map<string, boolean>()
|
|
const priorEnabledByUid = new Map<string, 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 priorEnabled = prior.enabled !== false
|
|
if (priorIban && !priorEnabledByIban.has(priorIban)) priorEnabledByIban.set(priorIban, priorEnabled)
|
|
if (!priorEnabledByUid.has(prior.uid)) priorEnabledByUid.set(prior.uid, priorEnabled)
|
|
}
|
|
|
|
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => {
|
|
const normalizedIban = normalizeIban(account.account_id?.iban)
|
|
return {
|
|
uid: account.uid,
|
|
iban: account.account_id?.iban,
|
|
bban: extractBban(account),
|
|
name: account.name || account.product,
|
|
currency: account.currency,
|
|
// Carry the user's earlier choice for an account we have seen before;
|
|
// only genuinely new accounts default to enabled. The picker shown
|
|
// right after this callback pre-checks from this flag, and no
|
|
// transactions are fetched before the user saves it.
|
|
enabled:
|
|
priorEnabledByUid.get(account.uid) ??
|
|
(normalizedIban ? priorEnabledByIban.get(normalizedIban) : undefined) ??
|
|
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,
|
|
}
|
|
})
|
|
|
|
// The maps above leave one corner open (issue #1709): a NO-IBAN account
|
|
// whose uid changed on an in-place reconnect matches neither by IBAN nor by
|
|
// uid, so its scope regenerates, every historical external_id changes, and
|
|
// the whole history re-imports as fresh unbooked rows. Pair such accounts by
|
|
// elimination, but only when the pairing is unambiguous: per currency,
|
|
// EXACTLY ONE prior account left unclaimed (no new account matched it via
|
|
// IBAN or uid) and EXACTLY ONE new account with a fresh scope, and neither
|
|
// side carries an IBAN. Anything else keeps the fresh-scope behavior. The
|
|
// asymmetry is deliberate: a wrong pairing can at worst skip a new
|
|
// transaction whose account+date+amount+occurrence all collide with an old
|
|
// row, while a missed pairing re-imports the full history unbooked.
|
|
const pairedPriorUidByNewUid = new Map<string, string>()
|
|
if (priorAccounts.length > 0) {
|
|
const newIbans = new Set<string>()
|
|
const newUids = new Set<string>()
|
|
for (const account of accountsMetadata) {
|
|
const normalizedIban = normalizeIban(account.iban)
|
|
if (normalizedIban) newIbans.add(normalizedIban)
|
|
newUids.add(account.uid)
|
|
}
|
|
const unclaimedPriorsByCurrency = new Map<string, StoredAccount[]>()
|
|
for (const prior of priorAccounts) {
|
|
const priorIban = normalizeIban(prior.iban)
|
|
if ((priorIban && newIbans.has(priorIban)) || newUids.has(prior.uid)) continue
|
|
const currency = (prior.currency || '').toUpperCase()
|
|
const bucket = unclaimedPriorsByCurrency.get(currency)
|
|
if (bucket) bucket.push(prior)
|
|
else unclaimedPriorsByCurrency.set(currency, [prior])
|
|
}
|
|
const freshScopeByCurrency = new Map<string, StoredAccount[]>()
|
|
for (const account of accountsMetadata) {
|
|
const normalizedIban = normalizeIban(account.iban)
|
|
const matchedPrior =
|
|
(normalizedIban ? priorScopeByIban.has(normalizedIban) : false) ||
|
|
priorScopeByUid.has(account.uid)
|
|
if (matchedPrior) continue
|
|
const currency = (account.currency || '').toUpperCase()
|
|
const bucket = freshScopeByCurrency.get(currency)
|
|
if (bucket) bucket.push(account)
|
|
else freshScopeByCurrency.set(currency, [account])
|
|
}
|
|
for (const [currency, unclaimed] of unclaimedPriorsByCurrency) {
|
|
const fresh = freshScopeByCurrency.get(currency) ?? []
|
|
if (unclaimed.length !== 1 || fresh.length !== 1) continue
|
|
const prior = unclaimed[0]
|
|
const survivor = fresh[0]
|
|
if (normalizeIban(prior.iban) || normalizeIban(survivor.iban)) continue
|
|
survivor.dedup_scope = prior.dedup_scope || prior.uid
|
|
// The pairing is an identity claim, so the user's earlier sync choice
|
|
// travels with it: a deselected account must not come back pre-checked.
|
|
survivor.enabled = prior.enabled !== false
|
|
pairedPriorUidByNewUid.set(survivor.uid, prior.uid)
|
|
console.log('[enable-banking] Paired no-IBAN account across a uid change', {
|
|
connectionId: pendingConnection.id,
|
|
currency,
|
|
priorUid: prior.uid,
|
|
newUid: survivor.uid,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Cross-company guard: at one-session banks (SEB) the PSU's single consent
|
|
// can cover accounts another of the user's companies already books. The
|
|
// deliberate reuse path (findReusableSessions) never offers a claimed IBAN,
|
|
// but this callback used to trust the session wholesale: a connect performed
|
|
// under company B stored company A's accounts pre-enabled and mirrored them
|
|
// into B's cash_accounts, one "Spara val" away from booking A's transactions
|
|
// in B's ledger. Accounts claimed elsewhere are stored disabled + flagged
|
|
// (the picker names the claiming company) and skipped by the mirror below.
|
|
// Accounts this row itself carried before keep their own state: the active
|
|
// company's standing choice outranks a sibling's claim, so a renewal can
|
|
// never switch a working feed off.
|
|
const crossCompany = await fetchCrossCompanyAccountContext(
|
|
supabase,
|
|
userId,
|
|
pendingConnection.company_id,
|
|
pendingConnection.id,
|
|
)
|
|
// Every account the guard itself disabled, whatever the branch. These are
|
|
// excluded from the cash_accounts mirror below: mirroring enabled:false for
|
|
// a new-to-row account can PROMOTE an existing manual holder (the seeded
|
|
// primary 1930 included) and flip it to disabled with a foreign identity,
|
|
// and a claimed account's row would double-claim the IBAN besides. The
|
|
// selection save allocates + mirrors any of them the user turns on.
|
|
const guardDisabledUids = new Set<string>()
|
|
let claimedCount = 0
|
|
for (const account of accountsMetadata) {
|
|
const normalizedIban = normalizeIban(account.iban)
|
|
// Row-local memory only. The active company's standing state on OTHER
|
|
// rows (a bank-list renewal arrives on a fresh row while the old row is
|
|
// waiting to be superseded) is already folded into crossCompany:
|
|
// activeCompanyIbans outrank claims and deselections there, so such
|
|
// accounts fall through to the enabled default below.
|
|
const seenOnThisRow =
|
|
priorEnabledByUid.has(account.uid) ||
|
|
(normalizedIban ? priorEnabledByIban.has(normalizedIban) : false) ||
|
|
pairedPriorUidByNewUid.has(account.uid)
|
|
if (seenOnThisRow) continue
|
|
|
|
if (crossCompany === null) {
|
|
// Fail closed: without the claim set a free account cannot be told from
|
|
// one another company books, and pre-checking a claimed account is the
|
|
// one outcome this guard must never produce. The user just re-ticks.
|
|
account.enabled = false
|
|
guardDisabledUids.add(account.uid)
|
|
continue
|
|
}
|
|
const claim = normalizedIban ? crossCompany.claims.get(normalizedIban) : undefined
|
|
if (claim) {
|
|
account.enabled = false
|
|
account.claimed_by_company_id = claim.companyId
|
|
if (claim.companyName) account.claimed_by_company_name = claim.companyName
|
|
guardDisabledUids.add(account.uid)
|
|
claimedCount += 1
|
|
continue
|
|
}
|
|
if (normalizedIban && crossCompany.deselectedIbans.has(normalizedIban)) {
|
|
// The user already said "Synkas ej" to this account on another
|
|
// connection row: a fresh row must not resurrect it pre-checked. The
|
|
// flag makes the picker say so; an unexplained unchecked box reads as
|
|
// a glitch and a silent one hides a sync gap.
|
|
account.enabled = false
|
|
account.deselected_elsewhere = true
|
|
guardDisabledUids.add(account.uid)
|
|
}
|
|
}
|
|
if (crossCompany === null) {
|
|
log.error('cross-company claim lookup failed: storing new accounts deselected', {
|
|
connectionId: pendingConnection.id,
|
|
})
|
|
} else if (claimedCount > 0) {
|
|
log.warn('session covers accounts claimed by sibling companies', {
|
|
connectionId: pendingConnection.id,
|
|
companyId: pendingConnection.company_id,
|
|
claimedCount,
|
|
accountCount: accountsMetadata.length,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
// can be pulled before the user has had a chance to deselect accounts.
|
|
// Do not set last_synced_at here either: no transactions have been fetched
|
|
// yet, and setting it would cause the cron's first-sync 90-day backfill
|
|
// path to be skipped. The first successful sync sets it.
|
|
const { data: updatedConnection, error: updateError } = await supabase
|
|
.from('bank_connections')
|
|
.update({
|
|
session_id,
|
|
status: 'pending_selection',
|
|
accounts_data: accountsMetadata,
|
|
consent_expires: consentExpiresAt,
|
|
oauth_state: null, // Clear to prevent replay
|
|
})
|
|
.eq('id', pendingConnection.id)
|
|
.select('id, bank_name, company_id, user_id')
|
|
.single()
|
|
|
|
if (updateError) {
|
|
console.error('[enable-banking] Failed to update connection after session creation', {
|
|
connectionId: pendingConnection.id,
|
|
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
|
|
sessionId: '[REDACTED]',
|
|
})
|
|
throw new Error(`Failed to update connection: ${updateError.message}`)
|
|
}
|
|
|
|
// A renewed consent belongs to every company that shared the old session,
|
|
// not just the one whose button was pressed. Without this the siblings keep
|
|
// pointing at the session the bank has just replaced and die on their next
|
|
// sync, which is the original one-session-per-PSU problem wearing a
|
|
// different hat. Non-fatal: this connection is already renewed and correct.
|
|
if (pendingConnection.session_id && pendingConnection.session_id !== session_id) {
|
|
try {
|
|
await fanOutSessionRenewal(supabase, {
|
|
oldSessionId: pendingConnection.session_id,
|
|
newSessionId: session_id,
|
|
consentExpires: consentExpiresAt ?? null,
|
|
excludeConnectionId: pendingConnection.id,
|
|
// Several ASPSPs mint new account uids on re-authorization, so the
|
|
// siblings need their stored uids re-pointed by IBAN too. Carrying the
|
|
// session id alone would leave them calling dead uids.
|
|
sessionAccounts: accountsMetadata,
|
|
})
|
|
} catch (renewalError) {
|
|
console.error('[enable-banking] Failed to carry renewed session to siblings', {
|
|
connectionId: pendingConnection.id,
|
|
message: renewalError instanceof Error ? renewalError.message : String(renewalError),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// clobber the user's remaps. Everything else goes through
|
|
// resolvePsd2LedgerAccount, which matches on IBAN before allocating: a
|
|
// re-authorization that mints new account uids, and a fresh connect that
|
|
// mints a whole new connection row, both have to land back on the mapping
|
|
// the user already chose instead of overflowing into the next free slots.
|
|
const { data: mirroredRows } = await supabase
|
|
.from('cash_accounts')
|
|
.select('id, external_uid, ledger_account')
|
|
.eq('company_id', updatedConnection.company_id)
|
|
.eq('bank_connection_id', updatedConnection.id)
|
|
const mirroredByUid = new Map(
|
|
((mirroredRows ?? []) as Array<{ id: string; external_uid: string; ledger_account: string }>).map(
|
|
(r) => [r.external_uid, r],
|
|
),
|
|
)
|
|
// Only ledgers still claimed by a uid the bank returned in THIS session
|
|
// block the resolver. A row whose uid the ASPSP retired on re-auth (SEB
|
|
// mints new uids on every renewal) is exactly the row the IBAN match must
|
|
// promote; seeding its ledger into the exclude set made the resolver reject
|
|
// its own IBAN hit and allocate a fresh 19xx slot per renewal, so the chart
|
|
// grew a dead sub-account each time. Stale ledgers are still safe from the
|
|
// allocator: findFreeLedgerAccount skips every ledger a cash_accounts row
|
|
// holds, whatever its uid.
|
|
const sessionUids = new Set(accountsMetadata.map((a) => a.uid))
|
|
const assignedLedgers = new Set<string>(
|
|
[...mirroredByUid.values()]
|
|
.filter((row) => sessionUids.has(row.external_uid))
|
|
.map((row) => row.ledger_account),
|
|
)
|
|
let accountsDataDirty = carriedScopeDirty
|
|
|
|
for (const account of accountsMetadata) {
|
|
// Nothing the guard disabled is mirrored here: a claimed account's row
|
|
// would be another company's data in this routing table (and an enabled
|
|
// one would double-claim the IBAN), and mirroring enabled:false for any
|
|
// new-to-row account can promote an existing manual holder — the seeded
|
|
// primary 1930 included — flipping it to disabled under a foreign
|
|
// identity. No 19xx slot is burned either. The selection save allocates
|
|
// and mirrors whichever of them the user deliberately turns on.
|
|
if (guardDisabledUids.has(account.uid)) continue
|
|
let targetLedger = mirroredByUid.get(account.uid)?.ledger_account
|
|
let reuseCashAccountId: string | null = null
|
|
if (!targetLedger) {
|
|
// A paired no-IBAN account (uid change on an in-place reconnect) reuses
|
|
// this connection's own row for the retired uid: same ledger, same row
|
|
// id. upsertFromPsd2 promotes the named row in place, re-keying it to
|
|
// the new uid, so transactions.cash_account_id links survive and the
|
|
// content-dedup account guard in lib/transactions/ingest.ts keeps
|
|
// matching. Without this the resolver would see the old row as a live
|
|
// claim and allocate an overflow 19xx slot plus a NEW cash_accounts row,
|
|
// which is the second half of issue #1709.
|
|
const pairedPriorUid = pairedPriorUidByNewUid.get(account.uid)
|
|
const pairedRow = pairedPriorUid ? mirroredByUid.get(pairedPriorUid) : undefined
|
|
if (pairedRow && !assignedLedgers.has(pairedRow.ledger_account)) {
|
|
targetLedger = pairedRow.ledger_account
|
|
reuseCashAccountId = pairedRow.id
|
|
}
|
|
}
|
|
if (!targetLedger) {
|
|
const resolved = await resolvePsd2LedgerAccount(
|
|
supabase,
|
|
updatedConnection.company_id,
|
|
updatedConnection.user_id,
|
|
{
|
|
iban: account.iban,
|
|
currency: account.currency,
|
|
accountName: account.name,
|
|
exclude: assignedLedgers,
|
|
},
|
|
)
|
|
targetLedger = resolved?.ledgerAccount ?? defaultLedgerForCurrency(account.currency)
|
|
reuseCashAccountId = resolved?.reuseCashAccountId ?? null
|
|
if (resolved?.source === 'iban') {
|
|
console.log('[enable-banking] Reused existing ledger mapping for known IBAN', {
|
|
connectionId: updatedConnection.id,
|
|
uid: account.uid,
|
|
ledgerAccount: targetLedger,
|
|
})
|
|
}
|
|
}
|
|
assignedLedgers.add(targetLedger)
|
|
if (account.ledger_account !== targetLedger) {
|
|
account.ledger_account = targetLedger
|
|
accountsDataDirty = true
|
|
}
|
|
try {
|
|
await upsertFromPsd2(supabase, updatedConnection.company_id, {
|
|
bank_connection_id: updatedConnection.id,
|
|
external_uid: account.uid,
|
|
currency: account.currency,
|
|
ledger_account: targetLedger,
|
|
iban: account.iban ?? null,
|
|
bban: account.bban ?? null,
|
|
name: account.name ?? null,
|
|
enabled: account.enabled ?? true,
|
|
reuse_cash_account_id: reuseCashAccountId,
|
|
})
|
|
} catch (cashErr) {
|
|
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
|
|
console.error('[enable-banking] Failed to mirror cash_account on callback', {
|
|
connectionId: updatedConnection.id,
|
|
uid: account.uid,
|
|
error: reason,
|
|
})
|
|
// Persist the failure to event_log so a security review can see that
|
|
// a PSD2 account returned by the bank was not mirrored into our
|
|
// routing table; otherwise this is only visible in console output
|
|
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'bank_connection.cash_account_mirror_failed',
|
|
payload: {
|
|
connectionId: updatedConnection.id,
|
|
bankName: updatedConnection.bank_name ?? null,
|
|
accountUid: account.uid,
|
|
ledgerAccount: targetLedger,
|
|
currency: account.currency,
|
|
reason,
|
|
userId: updatedConnection.user_id,
|
|
companyId: updatedConnection.company_id,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
// A.8.15: structured error (not bare console) so log-based alerting
|
|
// catches a dropped security event instead of it vanishing silently.
|
|
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
|
|
eventType: 'bank_connection.cash_account_mirror_failed',
|
|
connectionId: updatedConnection.id,
|
|
accountUid: account.uid,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist the allocated ledgers into accounts_data so the AccountPicker
|
|
// pre-fills the actual assignments instead of colliding currency
|
|
// defaults. Non-fatal: cash_accounts is the routing source of truth.
|
|
if (accountsDataDirty) {
|
|
const { error: accountsDataError } = await supabase
|
|
.from('bank_connections')
|
|
.update({ accounts_data: accountsMetadata })
|
|
.eq('id', updatedConnection.id)
|
|
if (accountsDataError) {
|
|
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
|
|
connectionId: updatedConnection.id,
|
|
error: accountsDataError.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
|
|
// ASVS V16 requires this transition to be logged as a security event; emit
|
|
// here so the event_log handler persists it (30-day TTL).
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'bank_connection.consent_granted',
|
|
payload: {
|
|
connectionId: updatedConnection.id,
|
|
bankName: updatedConnection.bank_name ?? null,
|
|
accountCount: accounts.length,
|
|
consentExpiresAt: consentExpiresAt ?? null,
|
|
userId: updatedConnection.user_id,
|
|
companyId: updatedConnection.company_id,
|
|
},
|
|
})
|
|
} catch (emitError) {
|
|
// Non-fatal: redirect the user even if the audit event fails. The
|
|
// structured error record is the alerting channel (A.8.15): production
|
|
// log monitoring keys on the stable message. The underlying DB write
|
|
// (the source of truth for the connection state) has already succeeded.
|
|
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
|
|
eventType: 'bank_connection.consent_granted',
|
|
connectionId: updatedConnection.id,
|
|
})
|
|
}
|
|
|
|
return `/settings/banking?select_accounts=${updatedConnection.id}`
|
|
}
|
|
|
|
/**
|
|
* Failure cleanup after finalizeConnection threw. A fresh connect (prior
|
|
* status 'pending') never became a connection: delete the row so it can't
|
|
* linger as a zombie "Åtgärd krävs" card next to a successful retry. A
|
|
* reconnect row (established connection) is kept and marked 'error' so the
|
|
* user retains the renew affordance. Returns the error redirect target.
|
|
*/
|
|
async function cleanupFailedFinalize(
|
|
supabase: ServiceClient,
|
|
pendingConnection: PendingConnection,
|
|
): Promise<string> {
|
|
try {
|
|
if (pendingConnection.status === 'pending') {
|
|
await supabase
|
|
.from('bank_connections')
|
|
.delete()
|
|
.eq('id', pendingConnection.id)
|
|
.eq('status', 'pending')
|
|
} else {
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({ status: 'error', error_message: FINALIZE_FAILED_MESSAGE, oauth_state: null })
|
|
.eq('id', pendingConnection.id)
|
|
.in('status', ['pending', 'expired', 'error'])
|
|
}
|
|
} catch (cleanupError) {
|
|
console.error('[enable-banking] Callback cleanup failed', {
|
|
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
|
})
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
bank_error: FINALIZE_FAILED_MESSAGE,
|
|
...(pendingConnection.bank_name ? { bank_name: pendingConnection.bank_name } : {}),
|
|
})
|
|
return `/settings/banking?${params.toString()}`
|
|
}
|