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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
abaab1bf8d
commit
1e1e1d5d17
@@ -1584,3 +1584,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user