fix(bank): Swedish provider errors and a durable failure trail for bank connect attempts (#1841)
Issue #1716: a user stuck in Handelsbankens fullmakt step got a raw provider token back (server_error, invalid_state) and support had nothing to look at afterwards: the failed pending row is deleted by design, the callback only logged to console (short retention), and event_log recorded successes only. Diagnosis of the reported case: the failures were on the bank's side (the corporate fullmakt requirement); both of the reporter's companies connected successfully on 2026-08-12 with no code change on our side in between, and the connections have been active and syncing since. Changes: - lib/errors/get-error-message.ts: getBankConnectionErrorMessage() maps PSD2 callback outcomes (access_denied, server_error, temporarily_unavailable, session expiry, plus the internal invalid_state, missing_parameters and invalid_code_format tokens) to Swedish user messages, appending the raw provider description so the underlying error is still surfaced. - callback route: every bank_error redirect and the stored error_message now carry the mapped Swedish text; bank_error_code, bank_name and psu_type still flow so the settings page keeps its targeted guidance (Handelsbanken fullmakt steps included). - New audit events bank_connection.consent_denied and bank_connection.finalize_failed are emitted on the two failure paths and persisted to event_log, so support can answer which attempt failed, with which provider error, on whose side, even after the row is gone. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
1307d4db2e
commit
64119d30bc
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/cash-accounts/service'
|
||||
import { fanOutSessionRenewal } from '@/extensions/general/enable-banking/lib/session-sharing'
|
||||
import { supersedeSiblingConnections } from '@/extensions/general/enable-banking/lib/supersede'
|
||||
import { getBankConnectionErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
|
||||
|
||||
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
||||
@@ -82,7 +83,11 @@ export async function GET(request: Request) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || 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 =
|
||||
@@ -104,7 +109,7 @@ export async function GET(request: Request) {
|
||||
// (which stays 'expired' during the round-trip) is also handled.
|
||||
const { data: pendingConn } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, bank_name, psu_type, status')
|
||||
.select('id, user_id, company_id, bank_name, psu_type, status')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
@@ -142,16 +147,41 @@ export async function GET(request: Request) {
|
||||
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
|
||||
.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: errorMessage,
|
||||
bank_error: userMessage,
|
||||
...(pendingConn.bank_name ? { bank_name: pendingConn.bank_name } : {}),
|
||||
bank_error_code: error,
|
||||
...(pendingConn.psu_type ? { psu_type: pendingConn.psu_type } : {}),
|
||||
@@ -164,18 +194,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(errorMessage)}`
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(userMessage)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(`${baseUrl}/settings/banking?bank_error=missing_parameters`)
|
||||
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=invalid_code_format`)
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(getBankConnectionErrorMessage('invalid_code_format'))}`
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
@@ -201,7 +235,7 @@ export async function GET(request: Request) {
|
||||
hasCode: !!code,
|
||||
})
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(getBankConnectionErrorMessage('invalid_state'))}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,13 +248,36 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
return await finalizeConnection(supabase, pendingConnection, code)
|
||||
} catch (finalizeError) {
|
||||
const reason =
|
||||
finalizeError instanceof Error ? finalizeError.message : String(finalizeError)
|
||||
console.error('[enable-banking] Callback error', {
|
||||
message: finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
|
||||
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)
|
||||
}
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user