Files
5eac2a492c 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>
2026-09-03 18:43:01 +02:00

828 lines
31 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock api-client
const mockGetAllTransactionsWithRaw = vi.fn()
const mockConvertTransaction = vi.fn()
const mockGetAccountBalance = vi.fn()
vi.mock('../api-client', () => ({
getAllTransactionsWithRaw: (...args: unknown[]) => mockGetAllTransactionsWithRaw(...args),
convertTransaction: (...args: unknown[]) => mockConvertTransaction(...args),
getAccountBalance: (...args: unknown[]) => mockGetAccountBalance(...args),
}))
// Mock document service
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
// Mock ingest
const mockIngest = vi.fn()
import { syncAccountTransactions } from '../sync'
import type { StoredAccount } from '../../types'
const USER_ID = 'user-1'
const COMPANY_ID = 'company-1'
const CONNECTION_ID = 'conn-1'
function makeAccount(overrides: Partial<StoredAccount> = {}): StoredAccount {
return {
uid: 'acc-uid-1',
currency: 'SEK',
...overrides,
}
}
describe('syncAccountTransactions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAccountBalance.mockRejectedValue(new Error('skip'))
mockIngest.mockResolvedValue({ imported: 1, duplicates: 0, errors: 0, reconciled: 0, auto_categorized: 0, auto_matched_invoices: 0, transaction_ids: ['tx-1'] })
})
it('calls uploadDocument for each raw page with correct filename pattern', async () => {
const rawPage1 = JSON.stringify({ transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' } }] })
const rawPage2 = JSON.stringify({ transactions: [{ transaction_amount: { amount: '200', currency: 'SEK' } }] })
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [
{ transaction_amount: { amount: '100', currency: 'SEK' } },
{ transaction_amount: { amount: '200', currency: 'SEK' } },
],
rawPages: [rawPage1, rawPage2],
})
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string } }) => ({
id: `tx-${tx.transaction_amount.amount}`,
date: '2024-06-15',
booking_date: '2024-06-15',
amount: parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'Test',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount()
await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2024-01-01',
'2024-12-31',
mockIngest
)
expect(mockUploadDocument).toHaveBeenCalledTimes(2)
// Verify filename pattern
const firstCall = mockUploadDocument.mock.calls[0]
expect(firstCall[3].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p1\.json$/)
expect(firstCall[3].type).toBe('application/json')
expect(firstCall[4]).toEqual({ upload_source: 'api' })
const secondCall = mockUploadDocument.mock.calls[1]
expect(secondCall[3].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p2\.json$/)
})
it('completes sync even if uploadDocument throws', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' } }],
rawPages: ['{"transactions":[]}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1',
date: '2024-06-15',
booking_date: '2024-06-15',
amount: 100,
currency: 'SEK',
description: 'Test',
})
mockUploadDocument.mockRejectedValue(new Error('Storage error'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const account = makeAccount()
const result = await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2024-01-01',
'2024-12-31',
mockIngest
)
expect(result.imported).toBe(1)
expect(result.errors).toBe(0)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to archive raw response'),
expect.any(Error)
)
errorSpy.mockRestore()
})
it('forwards strategy from sync options to getAllTransactionsWithRaw', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [],
rawPages: ['{}'],
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
makeAccount(),
'2024-01-01',
'2024-12-31',
mockIngest,
{ strategy: 'longest' }
)
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
'acc-uid-1',
'2024-01-01',
'2024-12-31',
'longest',
{ acceptedHistoryDays: undefined }
)
})
it('omits strategy when sync options do not include it', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [],
rawPages: ['{}'],
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
makeAccount(),
'2024-01-01',
'2024-12-31',
mockIngest
)
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
'acc-uid-1',
'2024-01-01',
'2024-12-31',
undefined,
{ acceptedHistoryDays: undefined }
)
})
it('passes raw transactions to ingest function', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-500',
date: '2024-06-15',
booking_date: '2024-06-15',
amount: -500,
currency: 'SEK',
description: 'Purchase',
counterparty_name: 'Store',
merchant_category_code: '5411',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount()
await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2024-01-01',
'2024-12-31',
mockIngest
)
expect(mockIngest).toHaveBeenCalledTimes(1)
const rawTxns = mockIngest.mock.calls[0][3]
expect(rawTxns).toHaveLength(1)
// Content-derived external_id: eb_{accountScope}_{date}_{öre}_{occurrence}.
// Deliberately NOT keyed off the bank's (unstable) tx id.
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_2024-06-15_-50000_0')
expect(rawTxns[0].import_source).toBe('enable_banking')
})
it('skips pending entries (no booking_date) and ingests only booked ones', async () => {
// A pending PSD2 entry has no booking_date: only a value_date. Importing it
// is what produced the production duplicates: its date (hence its dedup id)
// differs from the same transaction's booked representation. Pending entries
// must be skipped; booked ones import as usual, keyed on booking_date.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'T',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [
{ transaction_amount: { amount: '50', currency: 'SEK' }, value_date: '2026-02-15' }, // pending → skipped
{ transaction_amount: { amount: '75', currency: 'SEK' }, booking_date: '2026-03-01' }, // booked → kept
],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const batch = mockIngest.mock.calls[0][3]
expect(batch).toHaveLength(1)
expect(batch[0].date).toBe('2026-03-01')
expect(batch[0].external_id).toBe('eb_acc-uid-1_2026-03-01_-7500_0')
})
it('does not re-import a booked transaction when a later sync returns it pending (no booking_date)', async () => {
// Regression for the 45→90 duplication. The same transaction is returned
// booked in sync 1 (booking_date 2026-04-21) and pending in sync 2 (only
// value_date 2026-02-15). Previously sync 2 ingested the pending copy with a
// value_date-derived id (different date → new id → duplicate). It must now
// be skipped, so sync 2 ingests nothing for it.
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
id: 'x',
date: tx.booking_date || tx.value_date,
booking_date: tx.booking_date || tx.value_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'AVI ÖVERDRAG',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// Sync 1: booked
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-21', value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const firstBatch = mockIngest.mock.calls[0][3]
expect(firstBatch).toHaveLength(1)
expect(firstBatch[0].date).toBe('2026-04-21')
expect(firstBatch[0].external_id).toBe('eb_acc-uid-1_2026-04-21_-10000_0')
// Sync 2: same transaction now returned pending (booking_date dropped)
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, value_date: '2026-02-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2026-01-01', '2026-06-01', mockIngest
)
const secondBatch = mockIngest.mock.calls[1][3]
expect(secondBatch).toHaveLength(0)
})
it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => {
// Two genuinely distinct transactions that share date + amount must both be
// kept (distinct ids), and re-running the sync must reproduce the SAME set
// of ids so the second sync dedupes instead of duplicating.
const apiTxns = [
{ transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
{ transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
]
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: apiTxns, rawPages: ['{}'] })
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
id: `bank-id-${Math.random()}`, // unstable bank id: must NOT influence external_id
date: tx.booking_date,
booking_date: tx.booking_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'Kaffe',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
expect(ids).toEqual([
'eb_acc-uid-1_2024-06-15_-25000_0',
'eb_acc-uid-1_2024-06-15_-25000_1',
])
})
it('prefers IBAN over uid for the external_id account scope', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ iban: 'SE4550000000058398257466' }),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
})
it('normalizes IBAN whitespace/case so the account scope is stable across syncs', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// ASPSP returns the IBAN in grouped, lowercased display form.
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' }),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
// Same scope as the spaced/cased variant above.
expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
})
it('keeps external_ids identical across a uid change when dedup_scope is preserved', async () => {
// The renewal case for accounts WITHOUT an IBAN: many ASPSPs mint a fresh
// uid on re-authorization. dedup_scope pins the scope of the first ingest,
// so the re-synced ids must be byte-identical and collide on
// (company_id, external_id) instead of re-importing the history.
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ uid: 'uid-before-renewal', dedup_scope: 'uid-before-renewal' }),
'2024-06-01', '2024-06-30', mockIngest
)
const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
// Renewed consent: the ASPSP minted a NEW uid, the carried scope survives.
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ uid: 'uid-after-renewal', dedup_scope: 'uid-before-renewal' }),
'2024-06-01', '2024-06-30', mockIngest
)
const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id)
expect(firstIds).toEqual(['eb_uid-before-renewal_2024-06-15_10000_0'])
expect(secondIds).toEqual(firstIds)
})
it('prefers dedup_scope over both IBAN and uid for the external_id account scope', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
makeAccount({ iban: 'SE4550000000058398257466', dedup_scope: 'pinned-scope' }),
'2024-06-01', '2024-06-30', mockIngest
)
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
expect(ids).toEqual(['eb_pinned-scope_2024-06-15_10000_0'])
})
it('stamps dedup_scope with the scope used, so the accounts_data write-back persists it', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const withIban = makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, withIban,
'2024-06-01', '2024-06-30', mockIngest
)
expect(withIban.dedup_scope).toBe('SE4550000000058398257466')
const withoutIban = makeAccount()
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, withoutIban,
'2024-06-01', '2024-06-30', mockIngest
)
expect(withoutIban.dedup_scope).toBe('acc-uid-1')
})
it('reproduces the same SET of external_ids when a re-sync returns transactions in a different order', async () => {
// Two genuinely distinct same-day/same-amount transactions. A later sync may
// return them in any order; the dedupe guarantee is that the id SET is
// identical, so the re-sync collides on (company_id, external_id).
const mk = (booking_date: string, amount: string) => ({ transaction_amount: { amount, currency: 'SEK' }, booking_date })
const convert = (tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
id: `bank-${Math.random()}`, // unstable bank id: irrelevant to external_id
date: tx.booking_date,
booking_date: tx.booking_date,
amount: -parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'Lunch',
})
mockConvertTransaction.mockImplementation(convert)
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
// First sync order.
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2024-06-01', '2024-06-30', mockIngest
)
const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
// Re-sync, reversed order (and different lookback window does not matter).
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
rawPages: ['{}'],
})
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
'2024-03-01', '2024-06-30', mockIngest
)
const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id)
expect(new Set(firstIds)).toEqual(new Set(secondIds))
expect(firstIds).toHaveLength(2)
})
it('returns the min/max booking date the ASPSP returned for the activation UI', async () => {
// The min/max loop reads booking_date from the *raw* transactions (sync.ts:75-82),
// before convertTransaction runs: so the dates need to be set here.
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [
{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-15' },
{ transaction_amount: { amount: '200', currency: 'SEK' }, booking_date: '2026-02-20' },
{ transaction_amount: { amount: '300', currency: 'SEK' }, booking_date: '2026-05-10' },
],
rawPages: ['{}'],
})
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
id: `tx-${tx.transaction_amount.amount}`,
date: tx.booking_date,
booking_date: tx.booking_date,
amount: parseFloat(tx.transaction_amount.amount),
currency: 'SEK',
description: 'Test',
}))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount()
const result = await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-02-13',
'2026-05-13',
mockIngest
)
expect(result.returnedMinBookingDate).toBe('2026-02-20')
expect(result.returnedMaxBookingDate).toBe('2026-05-10')
})
it('returns undefined min/max when no transactions came back', async () => {
mockGetAllTransactionsWithRaw.mockResolvedValue({
transactions: [],
rawPages: [],
})
const account = makeAccount()
const result = await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-02-13',
'2026-05-13',
mockIngest
)
expect(result.returnedMinBookingDate).toBeUndefined()
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' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1',
date: '2026-04-01',
booking_date: '2026-04-01',
amount: 100,
currency: 'EUR',
description: 'EUR purchase',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount({ uid: 'eur-acc', currency: 'EUR', ledger_account: '1932' })
await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-02-13',
'2026-05-13',
mockIngest
)
const ingestOptions = mockIngest.mock.calls[0][4]
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', available: 1100.5 })
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.available_balance).toBe(1100.5)
expect(account.balance_updated_at).not.toBe(staleAt)
})
it('keeps the previous balance and timestamp when the bank reports no balances (null result)', async () => {
// A 200 with zero balances used to fabricate amount 0; with balances now
// user-facing that would pin "banken rapporterar 0 kr" for 12h.
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
mockGetAccountBalance.mockResolvedValue(null)
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
const account = makeAccount({ balance: 500, available_balance: 480, balance_updated_at: staleAt })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
'2026-01-01', '2026-06-01', mockIngest
)
expect(mockGetAccountBalance).toHaveBeenCalledTimes(1)
expect(account.balance).toBe(500)
expect(account.available_balance).toBe(480)
expect(account.balance_updated_at).toBe(staleAt)
})
it('clears a stored available balance when the refresh reports none', async () => {
// A stale available figure next to a fresh booked figure would misstate
// what can be spent: null from the bank overwrites, never keeps.
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01', available: null })
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
const account = makeAccount({ balance: 500, available_balance: 480, balance_updated_at: staleAt })
await syncAccountTransactions(
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
'2026-01-01', '2026-06-01', mockIngest
)
expect(account.balance).toBe(1234.56)
expect(account.available_balance).toBeUndefined()
})
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' }],
rawPages: ['{}'],
})
mockConvertTransaction.mockReturnValue({
id: 'tx-1',
date: '2026-04-01',
booking_date: '2026-04-01',
amount: 100,
currency: 'SEK',
description: 'SEK purchase',
})
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
const account = makeAccount() // No ledger_account
await syncAccountTransactions(
{} as never,
COMPANY_ID,
USER_ID,
CONNECTION_ID,
account,
'2026-02-13',
'2026-05-13',
mockIngest
)
const ingestOptions = mockIngest.mock.calls[0][4]
expect(ingestOptions.settlementAccount).toBeUndefined()
})
})