fix(enable-banking): stop reading every ASPSP_ERROR as a too-wide window (#2202) (#2247)

* fix(enable-banking): stop reading every ASPSP_ERROR as a too-wide window (#2202)

ASPSP_ERROR is Enable Banking's generic wrapper for any upstream bank
failure, so "history window beyond the PSD2 limit" and "the bank is
refusing right now" arrived as the same string, and every rejection
walked the whole 90/60/30 narrowing ladder: one user click cost up to
five upstream calls against a bank that was already saying no, the
failure surfaced with "förnya anslutningen" advice that fixes nothing,
and a sync that did narrow was reported as complete.

What the account has accepted before is the signal that tells the two
apart. sync.ts now records the widest window (days before date_to) each
account's bank has answered, on accounts_data as accepted_history_days
(no migration; persisted by the same write-back as dedup_scope). On a
rejected window: no wider than that = the bank is unavailable, stop after
one call; wider = one retry straight at the accepted width, then stop.
Without a record (first sync, legacy rows) the ladder runs as before, but
its exhaustion is now AspspUnavailableError too. The web sync route maps
that to 503 BANK_UNAVAILABLE with copy that says the connection does not
need renewing and leaves the row alone; the agent path keeps the contract
code BANK_SYNC_FAILED but no longer persists renewal advice.

getAllTransactionsWithRaw returns the requested and the effective
date_from plus a narrowed flag; the sync result and the /sync response
carry them (history_from), and the settings toast says from which date
the history is complete when the bank cut the window.

Not done: a per-account backoff for the user-triggered route (the agent
path already has the 15-minute lease from #2165), and using the
envelope's `detail` field (one sample, identical to a width rejection).

Closes #2202

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

* docs(decisions): carry the batch's decision lines (#2237, #2203, #2214) here

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 18:43:01 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 0c82db88da
commit 5eac2a492c
10 changed files with 547 additions and 25 deletions
@@ -152,7 +152,8 @@ describe('syncAccountTransactions', () => {
'acc-uid-1',
'2024-01-01',
'2024-12-31',
'longest'
'longest',
{ acceptedHistoryDays: undefined }
)
})
@@ -178,7 +179,8 @@ describe('syncAccountTransactions', () => {
'acc-uid-1',
'2024-01-01',
'2024-12-31',
undefined
undefined,
{ acceptedHistoryDays: undefined }
)
})
@@ -557,6 +559,101 @@ describe('syncAccountTransactions', () => {
expect(result.returnedMaxBookingDate).toBeUndefined()
})
// Issue #2202: ASPSP_ERROR cannot say whether the window was too wide or the
// bank is refusing right now, so the sync records the widest window each
// account's bank has answered and reports a narrowed sync as narrowed.
it('hands the accepted history width to the fetch and records the widest window the bank answered', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [],
rawPages: ['{}'],
requestedDateFrom: '2026-04-27',
effectiveDateFrom: '2026-06-26',
narrowed: true,
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const account = makeAccount({ accepted_history_days: 30 })
const result = await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-04-27',
'2026-08-19',
mockIngest
)
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
'acc-uid-1',
'2026-04-27',
'2026-08-19',
undefined,
{ acceptedHistoryDays: 30 }
)
// 2026-06-26 .. 2026-08-19 = 54 days: wider than the 30 on record.
expect(account.accepted_history_days).toBe(54)
expect(result).toMatchObject({
requestedFromDate: '2026-04-27',
effectiveFromDate: '2026-06-26',
historyNarrowed: true,
})
expect(warnSpy).toHaveBeenCalledWith(
'[enable-banking] Bank refused the requested history window; synced a narrower one',
expect.objectContaining({ requestedFromDate: '2026-04-27', effectiveFromDate: '2026-06-26' })
)
warnSpy.mockRestore()
})
it('never shrinks accepted_history_days: an incremental sync keeps the wider record', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [],
rawPages: ['{}'],
requestedDateFrom: '2026-08-12',
effectiveDateFrom: '2026-08-12',
narrowed: false,
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount({ accepted_history_days: 90 })
const result = await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-08-12',
'2026-08-19',
mockIngest
)
expect(account.accepted_history_days).toBe(90)
expect(result.historyNarrowed).toBe(false)
expect(result.effectiveFromDate).toBe('2026-08-12')
})
it('leaves accepted_history_days alone when the fetch reports no effective window (legacy shape)', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: ['{}'] })
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount()
const result = await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-08-12',
'2026-08-19',
mockIngest
)
expect(account.accepted_history_days).toBeUndefined()
expect(result.historyNarrowed).toBe(false)
expect(result.effectiveFromDate).toBeUndefined()
})
it('passes account.ledger_account as IngestOptions.settlementAccount when set', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'EUR' }, booking_date: '2026-04-01' }],