Files
accounted/extensions/general/enable-banking/lib/__tests__/sync.test.ts
T
Jakob Wennberg b06d73c23e 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>
2026-07-10 11:04:06 +02:00

610 lines
22 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'
)
})
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
)
})
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('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()
})
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' })
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' }],
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()
})
})