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:
Jakob Wennberg
2026-08-26 09:35:26 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 1307d4db2e
commit 64119d30bc
6 changed files with 335 additions and 17 deletions
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { getBankConnectionErrorMessage } from '../get-error-message'
// The PSD2 callback mapper (issue #1716): raw provider tokens used to reach
// the user verbatim ("server_error", "invalid_state"), which left them with
// nothing to act on and support with nothing to answer.
describe('getBankConnectionErrorMessage', () => {
it('maps a user cancel (access_denied) without echoing the provider text', () => {
const msg = getBankConnectionErrorMessage('access_denied', 'User cancelled')
expect(msg).toContain('Anslutningen avbröts hos banken')
expect(msg).not.toContain('User cancelled')
})
it('treats "Cancelled by user" descriptions as a cancel regardless of code', () => {
const msg = getBankConnectionErrorMessage('server_error', 'Cancelled by user')
expect(msg).toContain('Anslutningen avbröts hos banken')
})
it('maps a bare server_error to the bank-side failure explanation', () => {
const msg = getBankConnectionErrorMessage('server_error')
expect(msg).toContain('fel på bankens sida')
// The Handelsbanken corporate case: point at mandates without naming a bank
// (the settings page adds the bank-specific steps from bank_error_code).
expect(msg).toContain('fullmakt')
})
it('surfaces the provider description in parentheses on unknown codes', () => {
const msg = getBankConnectionErrorMessage('aspsp_error', 'PSU lacks corporate mandate')
expect(msg).toContain('Banken avvisade anslutningen')
expect(msg).toContain('(PSU lacks corporate mandate)')
})
it('does not duplicate the code when the description equals the code', () => {
const msg = getBankConnectionErrorMessage('server_error', 'server_error')
expect(msg).toContain('fel på bankens sida')
expect(msg).not.toContain('(server_error)')
})
it('maps session-expiry descriptions to the expired-session message', () => {
const msg = getBankConnectionErrorMessage('server_error', 'Session expired at ASPSP')
expect(msg).toContain('inloggningssession')
expect(msg).toContain('(Session expired at ASPSP)')
})
it('maps the internal invalid_state token to a retry explanation', () => {
const msg = getBankConnectionErrorMessage('invalid_state')
expect(msg).toContain('Starta bankkopplingen på nytt')
expect(msg).not.toContain('invalid_state')
})
it('maps missing_parameters and invalid_code_format to Swedish', () => {
expect(getBankConnectionErrorMessage('missing_parameters')).toContain('ofullständigt svar')
expect(getBankConnectionErrorMessage('invalid_code_format')).toContain('ogiltigt svar')
})
it('maps temporarily_unavailable to the try-later message', () => {
const msg = getBankConnectionErrorMessage('temporarily_unavailable')
expect(msg).toContain('tillfälligt otillgänglig')
})
it('falls back to a Swedish rejection message for unknown codes without description', () => {
const msg = getBankConnectionErrorMessage('weird_code')
expect(msg).toContain('Banken avvisade anslutningen')
expect(msg).not.toContain('weird_code')
})
})
+69
View File
@@ -574,6 +574,75 @@ export function getErrorMessage(
return pick(GENERIC_FALLBACK, locale)
}
// PSD2 bank-connection OAuth callback errors. The Enable Banking callback
// route redirects the browser back to /settings/banking with a user-facing
// message. The raw provider code/description used to be passed through
// verbatim ("server_error", "invalid_state"), which left a stuck user with
// nothing to act on and support with nothing to answer (issue #1716: the
// Handelsbanken corporate fullmakt failures). Known codes get a Swedish
// explanation; the raw provider description is appended in parentheses so
// the underlying error still reaches the user (and a screenshot to support).
const BANK_CONNECTION_ERROR_MAP: Record<string, string> = {
server_error:
'Banken kunde inte slutföra godkännandet på grund av ett fel på bankens sida. Försök igen om en stund. Gäller det företagskonton kan banken kräva en fullmakt innan kopplingen godkänns.',
temporarily_unavailable:
'Bankens anslutningstjänst är tillfälligt otillgänglig. Försök igen om en stund.',
invalid_request:
'Banken avvisade anslutningsförfrågan som ogiltig. Försök igen, och kontakta supporten om felet kvarstår.',
// Internal callback tokens (not from the bank) that were previously shown raw.
invalid_state:
'Anslutningsförsöket kunde inte matchas mot ett pågående försök. Det kan hända om försöket tog för lång tid eller om ett nytt försök startades under tiden. Starta bankkopplingen på nytt.',
missing_parameters:
'Banken skickade ett ofullständigt svar tillbaka. Starta bankkopplingen på nytt.',
invalid_code_format:
'Banken skickade ett ogiltigt svar tillbaka. Starta bankkopplingen på nytt.',
}
const BANK_CONNECTION_CANCELLED_MESSAGE =
'Anslutningen avbröts hos banken innan den slutfördes. Ingen bankkoppling skapades. Försök igen och slutför alla steg hos banken.'
const BANK_CONNECTION_SESSION_EXPIRED_MESSAGE =
'Bankens inloggningssession hann gå ut innan anslutningen slutfördes. Starta bankkopplingen på nytt och slutför alla steg hos banken direkt.'
const BANK_CONNECTION_FALLBACK_MESSAGE =
'Banken avvisade anslutningen. Försök igen, och kontakta supporten om felet kvarstår.'
// Same shape the callback route keys its expired-vs-error decision on.
const BANK_SESSION_EXPIRY_PATTERN =
/session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i
/**
* Map a PSD2 authorization callback outcome (OAuth error code plus optional
* provider description) to a Swedish user message. Always Swedish: the bank
* redirect carries no locale, and bank-connection surfaces follow the
* user-facing-errors-are-Swedish rule.
*/
export function getBankConnectionErrorMessage(
errorCode: string,
errorDescription?: string | null
): string {
const code = errorCode.trim()
const description = errorDescription?.trim() || null
const combined = `${code} ${description ?? ''}`
// User cancelled at the bank: an expected outcome, keep it clean without
// echoing the provider text back.
if (code === 'access_denied' || /cancel/i.test(combined)) {
return BANK_CONNECTION_CANCELLED_MESSAGE
}
let base: string
if (BANK_SESSION_EXPIRY_PATTERN.test(combined)) {
base = BANK_CONNECTION_SESSION_EXPIRED_MESSAGE
} else {
base = BANK_CONNECTION_ERROR_MAP[code] ?? BANK_CONNECTION_FALLBACK_MESSAGE
}
// Surface the underlying provider error: without it the user (and support,
// via a screenshot) cannot tell one failure from another.
return description && description !== code ? `${base} (${description})` : base
}
/**
* Helper that parses a Response body and returns a user-friendly error message.
*/
+6
View File
@@ -60,6 +60,12 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
// Bank connection consent lifecycle: required audit trail per ASVS V16
// and GDPR Art.30 (records of processing) for PSD2 consent decisions.
'bank_connection.consent_granted',
// Denied/failed consent attempts: the pending bank_connections row is
// deleted on failure and console logs expire, so this durable trail is the
// only way support can answer "which attempt, which error, whose side"
// (issue #1716).
'bank_connection.consent_denied',
'bank_connection.finalize_failed',
'bank_connection.account_selection_changed',
'bank_connection.revoked',
'bank_connection.superseded',
+29
View File
@@ -90,6 +90,35 @@ export type CoreEvent =
// superseded_by pointing at the replacement, and its transactions are
// re-pointed. connectionId is the SUPERSEDED (old) row, mirroring .revoked.
| { type: 'bank_connection.superseded'; payload: { connectionId: string; supersededById: string; bankName: string | null; userId: string; companyId: string } }
// Emitted when the bank/provider redirects the OAuth callback back with an
// error instead of an authorization code: denied consent, a bank-side
// failure (e.g. Handelsbanken's missing corporate fullmakt), or an expired
// signing session. Durable failure trail (issue #1716): the pending row is
// deleted right after and console logs expire, so without this event
// support cannot answer which attempt failed, with which error, on whose
// side. connectionId may reference a row deleted by the same request.
| { type: 'bank_connection.consent_denied'; payload: {
connectionId: string
bankName: string | null
psuType: string | null
errorCode: string
errorDescription: string | null
priorStatus: string
userId: string
companyId: string
} }
// Emitted when the code-for-session exchange or connection finalization
// throws after the bank redirected back successfully. Same audit doctrine
// as consent_denied: the fresh-connect row is deleted by cleanup and the
// console log expires, leaving support nothing to answer from.
| { type: 'bank_connection.finalize_failed'; payload: {
connectionId: string
bankName: string | null
reason: string
priorStatus: string
userId: string
companyId: string
} }
// Emitted when the PSD2 callback fails to mirror a returned account into
// cash_accounts. ASVS V16 / ISO 27001 A.8.15: security-relevant failures
// must land in a structured audit log (event_log, 30-day TTL) rather than