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:
co-authored by
Claude Fable 5
parent
6a9adcf00a
commit
b06d73c23e
@@ -506,6 +506,76 @@ describe('syncAccountTransactions', () => {
|
||||
expect(ingestOptions).toMatchObject({ settlementAccount: '1932' })
|
||||
})
|
||||
|
||||
it('skips the balance call when the stored balance is fresher than 12 hours', async () => {
|
||||
// PSD2 unattended consents allow only 4 BALANCES calls per account per
|
||||
// day; a fresh stored balance must not burn the quota on every manual sync.
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
|
||||
const freshAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
||||
const account = makeAccount({ balance: 500, balance_updated_at: freshAt })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
||||
'2026-01-01', '2026-06-01', mockIngest
|
||||
)
|
||||
|
||||
expect(mockGetAccountBalance).not.toHaveBeenCalled()
|
||||
expect(account.balance).toBe(500)
|
||||
expect(account.balance_updated_at).toBe(freshAt)
|
||||
})
|
||||
|
||||
it('refreshes the balance when the stored balance is older than 12 hours', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01' })
|
||||
|
||||
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
|
||||
const account = makeAccount({ balance: 500, balance_updated_at: staleAt })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
||||
'2026-01-01', '2026-06-01', mockIngest
|
||||
)
|
||||
|
||||
expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1')
|
||||
expect(account.balance).toBe(1234.56)
|
||||
expect(account.balance_updated_at).not.toBe(staleAt)
|
||||
})
|
||||
|
||||
it('treats a future balance_updated_at as stale and refreshes', async () => {
|
||||
// Clock skew or bad data can store a future timestamp; its negative age
|
||||
// must not count as fresh, or refreshes would be suppressed indefinitely.
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01' })
|
||||
|
||||
const futureAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
const account = makeAccount({ balance: 500, balance_updated_at: futureAt })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
||||
'2026-01-01', '2026-06-01', mockIngest
|
||||
)
|
||||
|
||||
expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1')
|
||||
expect(account.balance).toBe(1234.56)
|
||||
expect(account.balance_updated_at).not.toBe(futureAt)
|
||||
})
|
||||
|
||||
it('attempts a balance refresh when no timestamp is stored', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
|
||||
const account = makeAccount() // no balance_updated_at
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
||||
'2026-01-01', '2026-06-01', mockIngest
|
||||
)
|
||||
|
||||
// The beforeEach default rejects the call: the sync must survive that and
|
||||
// leave the timestamp unset (so the next sync tries again).
|
||||
expect(mockGetAccountBalance).toHaveBeenCalledTimes(1)
|
||||
expect(account.balance_updated_at).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits settlementAccount when account.ledger_account is unset (mapping engine defaults to 1930)', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
||||
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-01' }],
|
||||
|
||||
Reference in New Issue
Block a user