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
@@ -86,6 +86,45 @@ describe('api-client', () => {
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('does not retry a 429 whose body signals a daily quota', async () => {
// PSD2 unattended consents cap balance calls per DAY (observed body:
// "Consent daily limit 4 is exceeded"). A retry a second later cannot
// succeed against a daily quota, so it must fail fast.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchSpy.mockResolvedValueOnce(
new Response('{"message":"Consent daily limit 4 is exceeded"}', { status: 429 })
)
await expect(getAccountBalances('acc-1')).rejects.toThrow(
'Failed to get account balances (429)'
)
expect(fetchSpy).toHaveBeenCalledTimes(1)
warnSpy.mockRestore()
errorSpy.mockRestore()
})
it('still retries a 429 without a daily-limit body (transient rate limit)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchSpy
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ balances: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const result = await getAccountBalances('acc-1')
expect(result).toEqual([])
expect(fetchSpy).toHaveBeenCalledTimes(2)
warnSpy.mockRestore()
})
it('does not retry on 400 errors', async () => {
const badRequest = new Response('Bad Request', { status: 400 })
fetchSpy.mockResolvedValueOnce(badRequest)