fix(enable-banking): recover error-state connections, respect PSD2 balance quota, clean error surface (#968)

* fix(enable-banking): recover error-state connections, respect PSD2 balance quota, clean error surface

Three defects from the 2026-07-09 production log triage, all in how the
enable-banking extension handles upstream (Enable Banking / ASPSP) failures:

1. Retry dead-end: a non-session sync failure parked the connection in
   status='error', but POST /sync rejected anything not 'active' with 400,
   so the UI's "Försök igen" button could never succeed and the connection
   stayed stranded until a full re-auth. /sync now accepts 'error' (while
   still rejecting 'expired': a dead consent needs re-authorization), and a
   successful sync restores status='active' and clears error_message.

2. Balance quota burn: every sync (manual or cron) called the BALANCES
   endpoint although PSD2 unattended consents allow only 4 calls/day
   (observed 429 "Consent daily limit 4 is exceeded"), and the retry
   wrapper retried those 429s twice against a daily quota. The sync now
   skips the balance call while the stored balance_updated_at is fresher
   than 12 hours, and authenticatedFetchWithRetry fails fast on a 429
   whose body signals a daily limit.

3. Raw JSON in UI: sync failures persisted the raw English Enable Banking
   error body into bank_connections.error_message, which the settings
   panel renders verbatim. Failures are now mapped to short Swedish user
   messages (shared constants in api-client.ts); the raw body stays in
   server logs only.

Also ratchets the eslint baseline down by 1: the no-explicit-any disable
in the cron route was on the wrong line and never suppressed anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): treat future balance timestamps as stale (CodeRabbit)

A future balance_updated_at yielded a negative age that always passed the freshness check, suppressing balance refreshes indefinitely; only 0 <= age < BALANCE_MAX_AGE_MS now counts as fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-10 11:04:06 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6a9adcf00a
commit b06d73c23e
8 changed files with 401 additions and 23 deletions
+32 -8
View File
@@ -7,6 +7,8 @@ import {
deleteSession,
isSandboxMode,
SessionExpiredError,
REAUTH_REQUIRED_MESSAGE,
SYNC_FAILED_MESSAGE,
type ASPSP,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
@@ -443,7 +445,12 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.status !== 'active') {
// 'error' is retryable: a transient upstream failure (e.g. ASPSP_ERROR)
// parks the connection in 'error' while the PSD2 session is still
// alive, so the UI's "Försök igen" must be allowed through; a
// successful sync below restores 'active'. 'expired' stays rejected:
// a dead consent needs re-authorization via /connect, not a retry.
if (connection.status !== 'active' && connection.status !== 'error') {
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
}
@@ -556,6 +563,13 @@ export const enableBankingExtension: Extension = {
.update({
accounts_data: allAccounts,
last_synced_at: syncedAt,
// A successful sync proves the session works again: recover an
// 'error' connection to 'active' (so the cron picks it up again)
// and clear any stale failure message from the settings panel.
...(connection.status === 'error' ? { status: 'active' } : {}),
...(connection.status === 'error' || connection.error_message
? { error_message: null }
: {}),
})
.eq('id', connection.id)
@@ -601,15 +615,14 @@ export const enableBankingExtension: Extension = {
// 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 })
.update({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
.eq('id', connection.id)
.eq('company_id', companyId)
return NextResponse.json(
{
error: reauthMessage,
error: REAUTH_REQUIRED_MESSAGE,
code: 'SESSION_EXPIRED',
reauth_required: true,
connection_id: connection.id,
@@ -618,10 +631,21 @@ export const enableBankingExtension: Extension = {
)
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
)
// Non-session failure: the settings panel toasts this error verbatim
// and renders error_message on the connection card, so both must be
// the short Swedish message: the raw Enable Banking body (an English
// JSON envelope) is already in the server log above. Refresh the
// stored error_message on rows already in 'error' so a failed retry
// replaces any stale raw body persisted by older code.
if (connection.status === 'error') {
await supabase
.from('bank_connections')
.update({ error_message: SYNC_FAILED_MESSAGE })
.eq('id', connection.id)
.eq('company_id', companyId)
}
return NextResponse.json({ error: SYNC_FAILED_MESSAGE }, { status: 500 })
}
},
},