Files
accounted/extensions/general/enable-banking/lib/__tests__/sync.test.ts
T
Jakob WennbergandClaude Opus 4.7 722b22972d feat(enable-banking): per-account ledger mapping for multicurrency setups (#443) — rebase of #487 (#488)
* feat(enable-banking): per-account ledger mapping for multicurrency setups (#443)

Today every bank account routes to BAS 1930. Multicurrency setups (Wise,
SEB foreign-currency sub-accounts) get pooled into a single SEK ledger
account, making year-end FX revaluation a mess.

This change lets each bank account under a PSD2 consent map to its own BAS
account (SEK→1930, EUR→1932, USD→1933, etc.). The mapping engine already
honors IngestOptions.settlementAccount (lib/bookkeeping/mapping-engine.ts:55-57)
so the wiring is small:

- StoredAccount gains an optional ledger_account field (no migration —
  bank_connections.accounts_data is already JSONB).
- syncAccountTransactions passes account.ledger_account through as
  settlementAccount so the bank-side leg routes to the right BAS account.
- PATCH /accounts accepts account_mappings, validates 4-digit BAS format,
  and verifies each ledger_account exists in chart_of_accounts before
  persisting. Selection edits without account_mappings preserve existing
  values for back-compat.
- AccountPickerDialog shows a per-account "Bokför till konto" combobox
  populated from the company's 19xx accounts, with currency-based
  defaults (SEK→1930, EUR→1932, USD→1933, GBP→1934) and a non-blocking
  warning when two enabled accounts route to the same BAS account with
  different currencies.

Reconciliation still scans 1930 only — foreign-currency accounts skip
auto-matching until follow-up PR 4 (filed in the plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(enable-banking): address review feedback on PR #487

Two real bugs flagged in review:

1. AccountPickerDialog row wrapped a Radix <Checkbox> (which renders as its
   own <button role="checkbox">) inside <button onClick={toggle}>. Browsers
   silently flatten nested interactive elements, the Checkbox lost its
   onCheckedChange handler, and the toggle interaction was effectively
   broken. Reverted to <label> + <Checkbox onCheckedChange> with the <Select>
   as a sibling outside the label so clicking it doesn't also toggle.

2. BAS_ACCOUNT_PATTERN was /^[0-9]{4}$/ — accepted 3001 (revenue), 2640 (input
   VAT), or any 4-digit account that happens to exist in the chart. Direct
   API calls would bypass the UI's 19% picker filter and silently misroute
   every bank-side journal-entry leg into the wrong BAS class, corrupting both
   the ledger and momsdeklaration. Tightened to /^19[0-9]{2}$/ (kassa/bank only).

Tests updated to assert non-19xx rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(enable-banking): validate account_mappings UIDs against accounts_data

A typo'd UID in account_mappings was silently dropped — the entry never
landed in the resulting accounts_data while the response was still 200,
leaving the client to believe the mapping was applied. Mirror the existing
enabled_uids guard: reject unknown UIDs with 400 + unknown_uids in the body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(enable-banking): catch syncPromise rejections to prevent process crash

When the 60s timeout wins the Promise.race, the underlying Promise.all
keeps running. A subsequent bank-API rejection has no registered handler,
which surfaces as an unhandledRejection — Node 22 (the self-hosted Docker
runtime) terminates the process by default on those.

The cron self-heals via initial_sync_completed_at IS NULL, so a no-op
catch is the right policy: drop the late rejection, let the cron retry.

Caught by Greptile review on PR #488.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:59:29 +02:00

346 lines
10 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' } }],
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)
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500')
expect(rawTxns[0].import_source).toBe('enable_banking')
})
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('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()
})
})