From 1e1e1d5d172205b60b9c24542426cc916a357731 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:28:27 +0200 Subject: [PATCH] fix(enable-banking): connector-hop failures no longer park a connection in error (#2296) * fix(enable-banking): connector-hop failures no longer park a connection in error On 2026-09-04 the daily cron flipped four canary companies (bank sync routed through Accounted Connect, PR #2205) to 'error' with "Banksynkningen misslyckades ... forny anslutningen" because the service answered 200 with a body that fails bankSyncResponseSchema. The PSD2 sessions were fine; users re-authorized Danske, SEB and Revolut for nothing, and the bare "unexpected shape" message left the contract mismatch undiagnosable. - New ConnectorSyncError (status, code, body, Zod issue paths) thrown for every connector-hop failure: transport, timeout, error envelope other than a dead session, and wire-contract mismatch. The failing field paths are logged at the throw site. - Cron: AspspUnavailableError and ConnectorSyncError are transient. The row is left untouched (no status flip, no renewal advice) and logged at warn level; the health probe on the same run still catches a dead session. - Manual sync (POST /sync) and agent sync (triggerConnectionSync) answer retryable with CONNECTOR_UNAVAILABLE_MESSAGE, which says explicitly that the connection does not need renewing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L * fix(enable-banking): keep the connector response body out of the sync log line Superagent P2 and CodeRabbit: POST /sync logged up to 500 chars of the raw connector body next to user and connection ids. A connector response can carry transaction and personal data, so the log line now carries only code, status, the Zod issue paths and the body length. The BAD_SHAPE message no longer falls back to the body either. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + .../sync/cron/__tests__/route.test.ts | 56 ++++++++++++++ .../enable-banking/sync/cron/route.ts | 27 ++++++- .../__tests__/error-status-retry.test.ts | 27 ++++++- .../__tests__/sync-connector-mode.test.ts | 56 ++++++++++++-- extensions/general/enable-banking/index.ts | 28 +++++++ .../lib/__tests__/trigger-sync.test.ts | 14 +++- .../general/enable-banking/lib/api-client.ts | 37 ++++++++++ extensions/general/enable-banking/lib/sync.ts | 74 +++++++++++++------ .../enable-banking/lib/trigger-sync.ts | 18 +++++ 10 files changed, 304 insertions(+), 34 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 38db8ae9..469079ca 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1584,3 +1584,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names. [2026-09-04] reset_fiscal_year's next_year_dependency no longer counts an opening-balance verifikat in the following year as reliance (migration 20260904163000 redefines fiscal_year_reset_snapshot; the block now fires only when the following year is locked, closed or has its own closing entry). Why: the old check (opening_balance_entry_id / opening_balances_set on the next period) fired for the dominant migration shape, import the first year with its own #IB and later backfill the year before it, so a backfilled year could never be reset (Aisen & Adison AB, 2026-09-03), while a next year WITHOUT an IB, whose balansrapport really rolls from this year's books, was allowed: the check was inverted relative to actual reliance. An IB in the next year is its own verifikat with its own underlag and survives the reset untouched; the one IB that IS derived from this year's books, the bokslut-generated one, is still refused via this year's closing_entry_id (year_end_state). The preview now returns next_period {name, has_opening_balances} and the dialog says the following year's IB stays as it is, instead of a blocker. Rejected alternative: stornoing the next year's IB inside the reset (it would destroy a correct migration boundary and re-create the #1022 dead end). Sibling fix in the same change: CreatePeriodDialog now derives the name from the dates the user types until the name is hand-edited, which is how a 2022-07-01..2023-12-31 year got saved as "Räkenskapsår 2027" (the seed suggestion is always the next forward year). [2026-09-04] Underlag attach on a folder-picked Fortnox export (Loftux, 50 of 50 files refused with UNDERLAG_REF_MISMATCH): the multipart filename is reduced to its basename at the route boundary (lib/documents/upload-file-name.ts), rather than teaching the voucher-ref parser to strip directories or adding a client-supplied file_name field. Chrome writes webkitRelativePath as the multipart filename for folder selections, so the attach check saw "2026/06/Leverantörsfakturor/A166_x.pdf" while the preview had resolved File.name "A166_x.pdf"; the two endpoints received the same file under two names and the guard compared them. Stripping inside the parser would turn a typed manual ref "2024/01/31" into voucher 31 (the manual box shares the parser), and a second client-supplied name is no more trustworthy than the first, so the boundary is the only level that fixes the class. +[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths). diff --git a/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts b/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts index 585e003e..cc0f1280 100644 --- a/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts @@ -86,6 +86,8 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', async () => { import { REAUTH_REQUIRED_MESSAGE, SessionExpiredError, + AspspUnavailableError, + ConnectorSyncError, } from '@/extensions/general/enable-banking/lib/api-client' import { GET } from '../route' @@ -458,6 +460,60 @@ describe('GET /api/extensions/enable-banking/sync/cron: failure log level', () = }) }) +describe('GET /api/extensions/enable-banking/sync/cron: transient failures leave the row alone', () => { + // 2026-09-04: a connector contract mismatch parked four canary companies in + // 'error' with "förnya anslutningen", and users re-authorized consents that + // were fine. Neither a connector-hop failure nor a bank refusing right now + // says anything about the PSD2 session, so the row keeps its status and the + // health probe still checks the session. + it('does not park a connection in error when the connector hop fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + state.active = [connection()] + mocks.syncAccountTransactions.mockRejectedValue( + new ConnectorSyncError(200, 'CONNECTOR_BAD_SHAPE', '{"transactions":[]}', ['transactions.0.amount: Invalid input']), + ) + + const response = await GET(cronRequest()) + + expect(state.updates).toEqual([]) + await expect(response.json()).resolves.toMatchObject({ processed: 1 }) + const errorLines = consoleError.mock.calls.map(call => String(call[0])) + expect(errorLines.some(line => line.includes('sync failed for connection'))).toBe(false) + consoleError.mockRestore() + consoleWarn.mockRestore() + }) + + it('does not park a connection in error when the bank is refusing right now', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + state.active = [connection()] + mocks.syncAccountTransactions.mockRejectedValue( + new AspspUnavailableError(400, '{"error":"ASPSP_ERROR"}', 'window-already-accepted', '2026-08-28'), + ) + + await GET(cronRequest()) + + expect(state.updates).toEqual([]) + const errorLines = consoleError.mock.calls.map(call => String(call[0])) + expect(errorLines.some(line => line.includes('sync failed for connection'))).toBe(false) + consoleError.mockRestore() + consoleWarn.mockRestore() + }) + + it('still probes the session of a connection whose sync failed transiently', async () => { + state.active = [connection()] + state.probeCandidates = [connection()] + mocks.syncAccountTransactions.mockRejectedValue(new ConnectorSyncError(null, 'CONNECTOR_TIMEOUT', 'aborted')) + mocks.probeSessionHealth.mockResolvedValue('dead') + + await GET(cronRequest()) + + expect(mocks.probeSessionHealth).toHaveBeenCalledTimes(1) + expect(state.updates.at(-1)?.payload).toMatchObject({ status: 'expired' }) + }) +}) + describe('GET /api/extensions/enable-banking/sync/cron: incremental lookback', () => { const DAY_MS = 24 * 60 * 60 * 1000 // Pin the clock: the route reads Date.now() after the fixture does, and a diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index a5f43873..04853b8a 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -11,6 +11,8 @@ import { getDaysUntilExpiry, probeSessionHealth, SessionExpiredError, + AspspUnavailableError, + ConnectorSyncError, REAUTH_REQUIRED_MESSAGE, SYNC_FAILED_MESSAGE, } from '@/extensions/general/enable-banking/lib/api-client' @@ -385,6 +387,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { // the short Swedish user message in both cases: the raw Enable Banking // error body (an English JSON envelope) stays in the server log below. const isSessionDead = error instanceof SessionExpiredError + // A bank refusing right now, or the connector hop failing (timeout, + // error envelope, contract mismatch), says nothing about the PSD2 + // session: retryable, and the row is left alone. Parking it in 'error' + // with SYNC_FAILED_MESSAGE told users to renew a consent that was fine + // (four canary companies on 2026-09-04). The probe below still checks + // the session, so a dead one is caught anyway. + const isTransient = error instanceof AspspUnavailableError || error instanceof ConnectorSyncError const failureStatus = isSessionDead ? 'expired' : 'error' const failureMessage = isSessionDead ? REAUTH_REQUIRED_MESSAGE : SYNC_FAILED_MESSAGE @@ -403,14 +412,24 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { ...failureContext, reason: error instanceof Error ? error.message : String(error), }) + } else if (isTransient) { + ctx.log.warn('transient bank sync failure, connection left untouched', { + ...failureContext, + reason: error instanceof Error ? error.message : String(error), + ...(error instanceof ConnectorSyncError + ? { connectorCode: error.code, connectorStatus: error.status, issues: error.issues } + : { aspspReason: error instanceof AspspUnavailableError ? error.reason : undefined }), + }) } else { ctx.log.error('sync failed for connection', error as Error, failureContext) } - await supabase - .from('bank_connections') - .update({ status: failureStatus, error_message: failureMessage }) - .eq('id', connection.id) + if (!isTransient) { + await supabase + .from('bank_connections') + .update({ status: failureStatus, error_message: failureMessage }) + .eq('id', connection.id) + } results.push({ connectionId: connection.id, diff --git a/extensions/general/enable-banking/__tests__/error-status-retry.test.ts b/extensions/general/enable-banking/__tests__/error-status-retry.test.ts index 4b410d00..136c7cef 100644 --- a/extensions/general/enable-banking/__tests__/error-status-retry.test.ts +++ b/extensions/general/enable-banking/__tests__/error-status-retry.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/entitlements/has-capability', () => ({ requireCapability: vi.fn().mockResolvedValue(null), })) -import { SYNC_FAILED_MESSAGE } from '../lib/api-client' +import { SYNC_FAILED_MESSAGE, CONNECTOR_UNAVAILABLE_MESSAGE, ConnectorSyncError } from '../lib/api-client' import { enableBankingExtension } from '../index' import { syncAccountTransactions } from '../lib/sync' @@ -180,4 +180,29 @@ describe('POST /sync (enable-banking): retry from error status', () => { expect(body.error).not.toContain('ASPSP_ERROR') expect(updateSpy).toHaveBeenCalledWith({ error_message: SYNC_FAILED_MESSAGE }) }) + + it('answers 503 retryable without renewal advice when the connector hop fails, and leaves the row alone', async () => { + ;(syncAccountTransactions as unknown as Mock).mockRejectedValue( + new ConnectorSyncError(200, 'CONNECTOR_BAD_SHAPE', '{}', ['transactions.0.amount: Invalid input']) + ) + + const updateSpy = vi.fn() + const ctx = makeContext(makeConnection({ status: 'error', error_message: SYNC_FAILED_MESSAGE }), updateSpy) + + const res = await syncRoute.handler(makeRequest(), ctx) + + expect(res.status).toBe(503) + const body = await res.json() + expect(body).toMatchObject({ error: CONNECTOR_UNAVAILABLE_MESSAGE, code: 'CONNECTOR_UNAVAILABLE', retryable: true }) + expect(body.error).not.toContain('Förnya') + expect(updateSpy).not.toHaveBeenCalled() + expect(ctx.log.warn).toHaveBeenCalledWith( + '[enable-banking] Sync: connector hop failed', + expect.objectContaining({ code: 'CONNECTOR_BAD_SHAPE', issues: ['transactions.0.amount: Invalid input'] }) + ) + // The raw connector body can carry bank data: never in the log line. + const logged = (ctx.log.warn as Mock).mock.calls.find((c) => c[0] === '[enable-banking] Sync: connector hop failed')?.[1] + expect(logged).not.toHaveProperty('body') + expect(logged).toHaveProperty('bodyLength', 2) + }) }) diff --git a/extensions/general/enable-banking/__tests__/sync-connector-mode.test.ts b/extensions/general/enable-banking/__tests__/sync-connector-mode.test.ts index 5c8c15c4..7517e322 100644 --- a/extensions/general/enable-banking/__tests__/sync-connector-mode.test.ts +++ b/extensions/general/enable-banking/__tests__/sync-connector-mode.test.ts @@ -12,7 +12,7 @@ vi.mock('../lib/api-client', async () => { }) import { syncAccountTransactions } from '../lib/sync' -import { SessionExpiredError } from '../lib/api-client' +import { SessionExpiredError, ConnectorSyncError } from '../lib/api-client' import { buildStableExternalIds } from '@/lib/transactions/external-id' import type { StoredAccount } from '../types' @@ -84,11 +84,57 @@ describe('syncAccountTransactions in connector mode', () => { await expect(syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())).rejects.toBeInstanceOf(SessionExpiredError) }) - it('surfaces other connector failures as errors and refuses an unexpected response shape', async () => { + it('surfaces other connector failures as ConnectorSyncError, never as a dead session', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'busy', code: 'CONNECTOR_RATE_LIMITED' }), { status: 429 })) - await expect(syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())).rejects.toThrow(/CONNECTOR_RATE_LIMITED/) - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ transactions: 'nope' }), { status: 200 })) - await expect(syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn())).rejects.toThrow(/unexpected shape/) + const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn()) + await expect(failed).rejects.toThrow(/CONNECTOR_RATE_LIMITED/) + await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError) + await expect(failed).rejects.toMatchObject({ status: 429, code: 'CONNECTOR_RATE_LIMITED' }) + await expect(failed).rejects.not.toBeInstanceOf(SessionExpiredError) + }) + + it('refuses an unexpected response shape and names the failing fields', async () => { + // 2026-09-04: the service answered 200 with a body the contract rejects + // and four canary companies were parked in error with renewal advice. + // The field paths are what lets the service side be fixed. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + transactions: [{ booking_date: '2026-09-03', amount: '12.50', currency: 'SEK', description: 'x' }], + raw_pages: [], + skipped_pending: 0, + returned_min_booking_date: null, + returned_max_booking_date: null, + effective_date_from: null, + pages: 1, + }), { status: 200 })) + const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn()) + await expect(failed).rejects.toThrow(/unexpected shape/) + await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError) + const err = await failed.catch((e: unknown) => e) as ConnectorSyncError + expect(err.code).toBe('CONNECTOR_BAD_SHAPE') + expect(err.issues?.some((i) => i.startsWith('transactions.0.amount'))).toBe(true) + expect(err.issues?.some((i) => i.startsWith('transactions.0.counterparty_name'))).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[enable-banking] Connector sync response failed the wire contract', + expect.objectContaining({ connectionId: 'conn-1', accountUid: 'acc-1', issues: err.issues }), + ) + warn.mockRestore() + }) + + it('wraps the timeout abort as ConnectorSyncError CONNECTOR_TIMEOUT', async () => { + const abort = new Error('This operation was aborted') + abort.name = 'AbortError' + fetchMock.mockRejectedValueOnce(abort) + const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn()) + await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError) + await expect(failed).rejects.toMatchObject({ status: null, code: 'CONNECTOR_TIMEOUT' }) + }) + + it('wraps a transport failure as ConnectorSyncError', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')) + const failed = syncAccountTransactions(supabase, 'company-1', 'user-1', 'conn-1', account, '2026-08-01', '2026-09-03', vi.fn()) + await expect(failed).rejects.toBeInstanceOf(ConnectorSyncError) + await expect(failed).rejects.toMatchObject({ status: null, code: 'CONNECTOR_TRANSPORT' }) }) }) diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index da2cdf3d..16edba9c 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -8,6 +8,8 @@ import { isSandboxMode, SessionExpiredError, AspspUnavailableError, + ConnectorSyncError, + CONNECTOR_UNAVAILABLE_MESSAGE, REAUTH_REQUIRED_MESSAGE, SYNC_FAILED_MESSAGE, BANK_UNAVAILABLE_MESSAGE, @@ -957,6 +959,32 @@ export const enableBankingExtension: Extension = { ) } + // The connector hop failed (timeout, error envelope, contract + // mismatch): same treatment, the PSD2 session is not the problem + // and the row keeps whatever status it has. + if (error instanceof ConnectorSyncError) { + // Never the body: a connector response can carry transaction and + // personal data, and this log line sits next to user/connection ids. + log.warn('[enable-banking] Sync: connector hop failed', { + code: error.code, + status: error.status, + issues: error.issues, + bodyLength: error.body.length, + user_id: user.id, + connection_id, + bankName: connection.bank_name, + }) + return NextResponse.json( + { + error: CONNECTOR_UNAVAILABLE_MESSAGE, + code: 'CONNECTOR_UNAVAILABLE', + retryable: true, + connection_id: connection.id, + }, + { status: 503 } + ) + } + log.error('[enable-banking] Sync handler error', { message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, diff --git a/extensions/general/enable-banking/lib/__tests__/trigger-sync.test.ts b/extensions/general/enable-banking/lib/__tests__/trigger-sync.test.ts index de9cccb4..a8a96172 100644 --- a/extensions/general/enable-banking/lib/__tests__/trigger-sync.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/trigger-sync.test.ts @@ -16,7 +16,7 @@ vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: (...args: unknown[]) => mocks.emit(...args) }, })) -import { SessionExpiredError, REAUTH_REQUIRED_MESSAGE } from '../api-client' +import { SessionExpiredError, REAUTH_REQUIRED_MESSAGE, ConnectorSyncError } from '../api-client' import { SYNC_COOLDOWN_MS, triggerConnectionSync } from '../trigger-sync' const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' @@ -250,6 +250,18 @@ describe('triggerConnectionSync', () => { expect(await run()).toMatchObject({ ok: false, code: 'BANK_SYNC_NO_ACCOUNTS' }) }) + it('answers retryable and leaves the row alone when the connector hop fails', async () => { + state.connection = connection({ status: 'error', error_message: 'old' }) + mocks.syncAccountTransactions.mockRejectedValue(new ConnectorSyncError(null, 'CONNECTOR_TIMEOUT', 'aborted')) + const result = await run() + expect(result).toMatchObject({ ok: false, code: 'BANK_SYNC_FAILED', status: 'error' }) + expect(state.updates.some((u) => 'status' in u || 'error_message' in u)).toBe(false) + expect(log.warn).toHaveBeenCalledWith( + 'agent-triggered bank sync: connector hop failed', + expect.objectContaining({ code: 'CONNECTOR_TIMEOUT' }), + ) + }) + it('flips the connection to expired when the bank reports the session dead', async () => { mocks.syncAccountTransactions.mockRejectedValue(new SessionExpiredError(401, 'consent closed')) const result = await run() diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts index d6225dba..e55f3a13 100644 --- a/extensions/general/enable-banking/lib/api-client.ts +++ b/extensions/general/enable-banking/lib/api-client.ts @@ -350,6 +350,43 @@ export class AspspUnavailableError extends Error { } } +/** + * Thrown when the connector hop itself failed: the Accounted Connect service + * answered with an error envelope other than a dead session, timed out, could + * not be reached, or answered 200 with a body that fails the wire contract. + * None of these say anything about the PSD2 session, so callers treat it like + * AspspUnavailableError: retryable, no status flip, no renewal advice. On + * 2026-09-04 a contract mismatch parked four canary companies in 'error' with + * "förnya anslutningen" and cost them BankID round trips that fixed nothing. + * `issues` carries the failing field paths so the service side can be fixed + * from the server log instead of guessed at. `body` (head of the response) is + * kept for a debugger with the object in hand and is deliberately absent from + * the message and from every log line: a connector response can carry + * transaction and personal data. + */ +export class ConnectorSyncError extends Error { + constructor( + readonly status: number | null, + readonly code: string, + readonly body: string, + readonly issues?: string[] + ) { + super( + code === 'CONNECTOR_BAD_SHAPE' + ? `Connector bank sync answered with an unexpected shape: ${(issues ?? []).join('; ') || 'no issues reported'}` + : `Connector bank sync failed (${status ?? 'no response'} ${code})` + ) + this.name = 'ConnectorSyncError' + } +} + +/** + * User-facing message for a ConnectorSyncError. Says explicitly that the + * connection does NOT need renewing: the bank session is not the problem. + */ +export const CONNECTOR_UNAVAILABLE_MESSAGE = + 'Synkningen via Accounted Connect misslyckades tillfälligt. Försök igen om en stund. Anslutningen behöver inte förnyas.' + // API Helper async function authenticatedFetch( diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 0cd895e0..7a439709 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -1,5 +1,11 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import { getAllTransactionsWithRaw, convertTransaction, getAccountBalance, SessionExpiredError } from './api-client' +import { + getAllTransactionsWithRaw, + convertTransaction, + getAccountBalance, + SessionExpiredError, + ConnectorSyncError, +} from './api-client' import { historyWindowDays } from './history-window' import { bankSyncResponseSchema, connectorErrorSchema } from '@accounted/connect-contract' import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams' @@ -113,26 +119,37 @@ async function fetchBookedViaConnector( let response: Response let text: string try { - response = await fetch(`${connector.baseUrl}/sync`, { - method: 'POST', - signal: controller.signal, - redirect: 'error', - headers: { - Authorization: `Bearer ${connector.key}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - [CONNECTOR_COMPANY_HEADER]: args.companyId, - }, - body: JSON.stringify({ - session_id: sessionId, - account_uid: args.account.uid, - account_currency: args.account.currency, - date_from: args.fromDate, - date_to: args.toDate, - ...(args.strategy ? { strategy: args.strategy } : {}), - }), - }) - text = await response.text() + try { + response = await fetch(`${connector.baseUrl}/sync`, { + method: 'POST', + signal: controller.signal, + redirect: 'error', + headers: { + Authorization: `Bearer ${connector.key}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + [CONNECTOR_COMPANY_HEADER]: args.companyId, + }, + body: JSON.stringify({ + session_id: sessionId, + account_uid: args.account.uid, + account_currency: args.account.currency, + date_from: args.fromDate, + date_to: args.toDate, + ...(args.strategy ? { strategy: args.strategy } : {}), + }), + }) + text = await response.text() + } catch (err) { + // Transport failure or the abort above: the connector hop failed, the + // PSD2 session is untouched. Never a status flip (ConnectorSyncError). + const aborted = err instanceof Error && err.name === 'AbortError' + throw new ConnectorSyncError( + null, + aborted ? 'CONNECTOR_TIMEOUT' : 'CONNECTOR_TRANSPORT', + err instanceof Error ? err.message : String(err), + ) + } } finally { clearTimeout(timeout) } @@ -148,10 +165,21 @@ async function fetchBookedViaConnector( if (response.status === 410 || code === 'CONNECTOR_BANK_SESSION_EXPIRED') { throw new SessionExpiredError(response.status, text) } - throw new Error(`Connector bank sync failed (${response.status} ${code})`) + throw new ConnectorSyncError(response.status, code, text.slice(0, 500)) } const parsed = bankSyncResponseSchema.safeParse(json) - if (!parsed.success) throw new Error('Connector bank sync answered with an unexpected shape') + if (!parsed.success) { + // The field paths are the only thing that lets the service side be fixed: + // a bare "unexpected shape" left the 2026-09-04 canary failure undiagnosable. + const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) + console.warn('[enable-banking] Connector sync response failed the wire contract', { + connectionId: args.connectionId, + accountUid: args.account.uid, + status: response.status, + issues, + }) + throw new ConnectorSyncError(response.status, 'CONNECTOR_BAD_SHAPE', text.slice(0, 500), issues) + } return parsed.data } diff --git a/extensions/general/enable-banking/lib/trigger-sync.ts b/extensions/general/enable-banking/lib/trigger-sync.ts index 6129e41d..b3930fb1 100644 --- a/extensions/general/enable-banking/lib/trigger-sync.ts +++ b/extensions/general/enable-banking/lib/trigger-sync.ts @@ -27,6 +27,7 @@ import { syncAccountTransactions, type SyncOptions } from './sync' import { SessionExpiredError, AspspUnavailableError, + ConnectorSyncError, REAUTH_REQUIRED_MESSAGE, SYNC_FAILED_MESSAGE, } from './api-client' @@ -295,6 +296,23 @@ export async function triggerConnectionSync( } } + if (error instanceof ConnectorSyncError) { + // The connector hop failed: same treatment as the bank being + // unavailable. The session is fine and the row is left alone. + log.warn('agent-triggered bank sync: connector hop failed', { + connectionId, + code: error.code, + status: error.status, + issues: error.issues, + }) + return { + ok: false, + code: 'BANK_SYNC_FAILED', + connection_id: connectionId, + status: connection.status as string, + } + } + log.error('agent-triggered bank sync failed', { connectionId, message: error instanceof Error ? error.message : String(error),