* 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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0c82db88da
commit
5eac2a492c
@@ -18,6 +18,7 @@ import {
|
||||
getAccountTransactions,
|
||||
getAllTransactions,
|
||||
getAllTransactionsWithRaw,
|
||||
AspspUnavailableError,
|
||||
convertTransaction,
|
||||
deleteSession,
|
||||
probeSessionHealth,
|
||||
@@ -443,9 +444,11 @@ describe('api-client', () => {
|
||||
// Fresh Response per call: a body can only be read once.
|
||||
fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
|
||||
|
||||
await expect(
|
||||
getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
|
||||
).rejects.toThrow('Failed to get transactions (400)')
|
||||
const failure = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07').catch((e) => e)
|
||||
expect(failure).toBeInstanceOf(AspspUnavailableError)
|
||||
expect(failure.message).toContain('Failed to get transactions (400)')
|
||||
// Every narrower window refused too: the bank is refusing, not the width.
|
||||
expect(failure.reason).toBe('ladder-exhausted')
|
||||
|
||||
// full window + 90 + 60 + 30 = 4 attempts, then give up
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4)
|
||||
@@ -453,6 +456,130 @@ describe('api-client', () => {
|
||||
warnSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reports the requested and the effective date_from, and whether the window was narrowed', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
) // 90 days → success
|
||||
|
||||
const result = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
|
||||
expect(result).toMatchObject({
|
||||
requestedDateFrom: '2026-02-07',
|
||||
effectiveDateFrom: '2026-03-09',
|
||||
narrowed: true,
|
||||
})
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reports narrowed: false when the first call succeeds', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
const result = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
|
||||
expect(result).toMatchObject({
|
||||
requestedDateFrom: '2026-02-07',
|
||||
effectiveDateFrom: '2026-02-07',
|
||||
narrowed: false,
|
||||
})
|
||||
})
|
||||
|
||||
// Issue #2202: Länsförsäkringar answered a 4-month window at 23:07 (after
|
||||
// narrowing to 06-26) and refused every rung of the same request at
|
||||
// 23:14. ASPSP_ERROR is the same string for "too wide" and "the bank is
|
||||
// refusing right now"; what the account has accepted before is the
|
||||
// signal that tells them apart.
|
||||
describe('accepted history width', () => {
|
||||
it('a rejected window no wider than the accepted width stops after ONE call, as the bank being unavailable', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
|
||||
|
||||
// 2026-02-07 .. 2026-06-07 is 120 days; the bank has answered 120 before.
|
||||
const failure = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', undefined, {
|
||||
acceptedHistoryDays: 120,
|
||||
}).catch((e) => e)
|
||||
|
||||
expect(failure).toBeInstanceOf(AspspUnavailableError)
|
||||
expect(failure.reason).toBe('window-already-accepted')
|
||||
expect(failure.dateFrom).toBe('2026-02-07')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('a rejected wider window jumps straight to the accepted width, then stops', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
|
||||
|
||||
// Accepted 54 days before; asking for 120. No 90/60/30 ladder walk.
|
||||
const failure = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', undefined, {
|
||||
acceptedHistoryDays: 54,
|
||||
}).catch((e) => e)
|
||||
|
||||
expect(failure).toBeInstanceOf(AspspUnavailableError)
|
||||
expect(failure.reason).toBe('window-already-accepted')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
|
||||
expect(urls[0]).toContain('date_from=2026-02-07')
|
||||
expect(urls[1]).toContain('date_from=2026-04-14') // 54 days before 2026-06-07
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('a rejected wider window that succeeds at the accepted width is reported as narrowed to it', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', undefined, {
|
||||
acceptedHistoryDays: 54,
|
||||
})
|
||||
expect(result).toMatchObject({ effectiveDateFrom: '2026-04-14', narrowed: true })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('still drops an unsupported strategy before judging the window', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
|
||||
|
||||
const failure = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', 'longest', {
|
||||
acceptedHistoryDays: 120,
|
||||
}).catch((e) => e)
|
||||
|
||||
expect(failure).toBeInstanceOf(AspspUnavailableError)
|
||||
// strategy=longest, then the same window without strategy, then stop.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('getAllTransactions applies the same policy', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
|
||||
|
||||
const failure = await getAllTransactions('acc-1', '2026-02-07', '2026-06-07', undefined, {
|
||||
acceptedHistoryDays: 120,
|
||||
}).catch((e) => e)
|
||||
|
||||
expect(failure).toBeInstanceOf(AspspUnavailableError)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
warnSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user