From 7cf15a105fc96745899a18cb19e7ae9e04978bf9 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 24 Aug 2026 13:26:08 +0200 Subject: [PATCH] fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831) * fix(bookkeeping): settle unbound transactions on the company's single enabled cash account A transaction with no cash_account_id booked its bank leg on the hardcoded 1930 from the standard templates and category mappings even when the company's only bank account is e.g. 1920 (PlusGiro), while the booking dialogs previewed the right account via the client-side resolveAccount fallback. resolveSettlementAccount now mirrors that fallback: with a NULL cash_account_id it lists the company's enabled cash accounts and, when EXACTLY ONE matches the transaction's currency, settles there; zero or several candidates keep the 1930 fallback. The explicit-cash_account_id branch (including its throw-on-error path, issue #842) is byte-identical. Transaction currency is threaded into the categorize, batch-categorize, pending-operation edit, MCP staging, and invoice-inbox call sites; other callers get the SEK default. Forward-only: historical wrong verifikat are corrected only via the existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md). Fixes #1722 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF * test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups The merge of main (PR #1828) into this branch combined two changes that each add one query to the categorize commit flow; the strictly ordered queued mock in the allow_duplicate test needed the cash_accounts listing entry inserted between the period lookup and the pre-FY clamp lookup. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../[id]/__tests__/route.test.ts | 5 + .../[id]/commit/__tests__/route.test.ts | 2 + app/api/pending-operations/[id]/route.ts | 1 + .../[id]/categorize/__tests__/route.test.ts | 86 ++++++++++++- app/api/transactions/[id]/categorize/route.ts | 1 + .../match-invoice/__tests__/route.test.ts | 34 +++-- .../__tests__/route.test.ts | 6 +- .../transactions/[id]/categorize/route.ts | 1 + .../transactions/batch-categorize/route.ts | 1 + extensions/general/invoice-inbox/index.ts | 1 + .../categorize-account-override.test.ts | 4 + .../categorize-category-required.test.ts | 1 + .../categorize-duplicate-message.test.ts | 3 + .../__tests__/dimension-tools.test.ts | 4 +- .../__tests__/receipt-matcher.test.ts | 2 + extensions/general/mcp-server/server.ts | 1 + .../__tests__/settlement-account.test.ts | 103 ++++++++++++++- lib/bookkeeping/settlement-account.ts | 47 ++++++- .../__tests__/commit-duplicate-guard.test.ts | 1 + ...saction-invoice-settlement-account.test.ts | 9 +- .../__tests__/categorize-core.bulk.test.ts | 10 +- .../categorize-core.override.test.ts | 8 ++ ...ategorize-core.settlement-fallback.test.ts | 118 ++++++++++++++++++ lib/transactions/categorize-core.ts | 1 + 24 files changed, 429 insertions(+), 21 deletions(-) create mode 100644 lib/transactions/__tests__/categorize-core.settlement-fallback.test.ts diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts index 81eb48fd..8ee9adbe 100644 --- a/app/api/pending-operations/[id]/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/__tests__/route.test.ts @@ -194,6 +194,7 @@ describe('PATCH /api/pending-operations/[id]', () => { }, }) enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { id: 'op-1', @@ -247,6 +248,7 @@ describe('PATCH /api/pending-operations/[id]', () => { }) enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } }) enqueue({ data: { entity_type: 'aktiebolag' } }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } }) const mapping = { @@ -338,6 +340,7 @@ describe('PATCH /api/pending-operations/[id]', () => { }) enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } }) enqueue({ data: { entity_type: 'enskild_firma' } }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } }) const res = await PATCH( @@ -381,6 +384,7 @@ describe('PATCH /api/pending-operations/[id]', () => { }) enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } }) enqueue({ data: { entity_type: 'enskild_firma' } }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } }) const res = await PATCH( @@ -473,6 +477,7 @@ describe('PATCH /api/pending-operations/[id]', () => { }) enqueue({ data: { id: 'tx-1', amount: -100, currency: 'SEK' } }) enqueue({ data: { entity_type: 'enskild_firma' } }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mappingMock.mockReturnValueOnce({ debit_account: null, credit_account: null, diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index cf131b0f..6925690e 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -133,6 +133,7 @@ describe('POST /api/pending-operations/:id/commit', () => { { data: { id: 'op-1' } }, // CAS claim { data: tx }, // fetch transaction { data: settings }, // fetch company settings + { data: [] }, // resolveSettlementAccount: no cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, // fiscal period check { data: [{ id: 'tx-1' }], error: null }, // transaction CAS matched { data: null, error: null }, // upsert counterparty template @@ -164,6 +165,7 @@ describe('POST /api/pending-operations/:id/commit', () => { { data: { id: 'op-1' } }, // CAS claim (pending -> committing) { data: tx }, // fetch transaction { data: settings }, // fetch company settings + { data: [] }, // resolveSettlementAccount: no cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, // fiscal period exists { data: null, error: null }, // dispatcher releases op back to 'pending' ]) diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index a2beae8c..5e665dc3 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -176,6 +176,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( companyId, (tx as Transaction).cash_account_id, log, + (tx as Transaction).currency, ) mapping = applySettlementAccount(mapping, settlementAccount) } catch (err) { diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index df7744b5..40def0db 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -138,6 +138,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // fetch transaction enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) // ensureFiscalPeriod mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -237,6 +238,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValueOnce(null) enqueue({ data: [], error: null }) @@ -267,6 +269,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // Fetch company settings enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // ensureFiscalPeriod: check existing enqueue({ data: [{ id: 'period-1' }], error: null }) @@ -310,6 +313,61 @@ describe('POST /api/transactions/[id]/categorize', () => { ) }) + it('books a standardmall bank leg on the single enabled cash account when cash_account_id is NULL (#1722)', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'Banken', + journal_entry_id: null, + cash_account_id: null, + }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // Fetch company settings + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + // resolveSettlementAccount currency fallback: the company's ONLY enabled + // SEK cash account is a PlusGiro on 1920, so the template's hardcoded + // 1930 leg must be rewritten to 1920 before posting. + enqueue({ data: [{ ledger_account: '1920' }], error: null }) + // ensureFiscalPeriod: check existing + enqueue({ data: [{ id: 'period-1' }], error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockSaveUserMappingRule.mockResolvedValue(undefined) + + // Update transaction (CAS guard: returns matched row) + enqueue({ data: [{ id: 'tx-1' }], error: null }) + enqueue({ data: [], error: null }) // inbox propagation: no matched items + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, template_id: 'bank_fees' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(true) + // The posted mapping carries the real settlement account, not the + // template's hardcoded 1930 (bank_fees is Dr 6570 / Cr 1930). + const mappingArg = mockCreateTransactionJournalEntry.mock.calls[0][4] as { + debit_account: string + credit_account: string + } + expect(mappingArg.debit_account).toBe('6570') + expect(mappingArg.credit_account).toBe('1920') + // The fallback listing was narrowed to enabled accounts in the + // transaction's currency. + const eqArgs = findCalls('cash_accounts', 'eq') + expect(eqArgs).toContainEqual(['enabled', true]) + expect(eqArgs).toContainEqual(['currency', 'SEK']) + }) + it('books a transaction dated before the first fiscal year without minting a period (pre-FY, issue #1825)', async () => { const tx = makeTransaction({ id: 'tx-1', @@ -322,6 +380,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // fetch transaction enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [], error: null }) // ensureFiscalPeriod: no covering open period enqueue({ data: [{ period_start: '2026-05-12' }], error: null }) // ensureFiscalPeriod: earliest period @@ -350,7 +409,6 @@ describe('POST /api/transactions/[id]/categorize', () => { // The pre-FY guard must not upsert a calendar-year (pre-registration) period. expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(0) }) - it('atomically unignores an ignored transaction when categorizing it', async () => { const tx = makeTransaction({ id: 'tx-1', @@ -362,6 +420,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: [{ ...tx, is_business: false, category: 'private', is_ignored: false, journal_entry_id: 'je-1' }], error: null }) @@ -404,6 +463,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // fetch transaction enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched) @@ -461,6 +521,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // fetch transaction enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched) @@ -495,6 +556,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update @@ -522,6 +584,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked')) @@ -558,6 +621,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80)) @@ -595,6 +659,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -629,6 +694,7 @@ describe('POST /api/transactions/[id]/categorize', () => { }) enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ @@ -670,6 +736,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -698,6 +765,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -744,6 +812,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -784,6 +853,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -843,6 +913,7 @@ describe('POST /api/transactions/[id]/categorize', () => { it('EUR transaction: a 1 000 SEK supplier invoice is not suggested for a 1 000 EUR payment', async () => { enqueue({ data: eurExpenseTx(), error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -877,6 +948,7 @@ describe('POST /api/transactions/[id]/categorize', () => { it('EUR transaction with a rate: the 11 500 SEK supplier invoice IS suggested', async () => { enqueue({ data: eurExpenseTx(), error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -907,6 +979,7 @@ describe('POST /api/transactions/[id]/categorize', () => { it('EUR transaction without a rate: kronor invoices are excluded, never compared raw', async () => { enqueue({ data: eurExpenseTx({ exchange_rate: null }), error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -935,6 +1008,7 @@ describe('POST /api/transactions/[id]/categorize', () => { it('EUR transaction: a 1 000 EUR supplier invoice still matches in its own currency', async () => { enqueue({ data: eurExpenseTx(), error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -980,6 +1054,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -1039,6 +1114,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -1093,6 +1169,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -1151,6 +1228,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult, @@ -1217,6 +1295,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // fetch enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) // ensureFiscalPeriod existing check mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) mockSaveUserMappingRule.mockResolvedValue(undefined) @@ -1267,6 +1346,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -1302,6 +1382,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) // Fetch company settings enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // Mapping built from category, but the debit account is missing/inactive // in this company's kontoplan. findMissingActiveAccounts is mocked at the @@ -1337,6 +1418,7 @@ describe('POST /api/transactions/[id]/categorize', () => { }) enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // Multiple accounts missing: covers the common "imported a template with // accounts that this kontoplan never enabled" case. @@ -1370,6 +1452,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // ensureFiscalPeriod existing-period check enqueue({ data: [{ id: 'period-1' }], error: null }) @@ -1414,6 +1497,7 @@ describe('POST /api/transactions/[id]/categorize', () => { enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // chart_of_accounts lookup for '5420': not in the company's chart. // Using a plain expense account (Programvaror) avoids the implication // that 4535 (Inköp av varor från annat EU-land, reverse-charge) would diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index d45a3863..fa259467 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -358,6 +358,7 @@ export const POST = withRouteContext( companyId!, transaction.cash_account_id, txLog, + transaction.currency, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 71def122..f25c65b4 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -294,6 +294,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', @@ -397,6 +398,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockFetchExchangeRate.mockResolvedValue(null) // Riksbanken down: manual rate used instead mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx-manual' }) @@ -456,6 +458,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -555,11 +558,11 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockReverseEntry.mockResolvedValue({ id: 'je-storno' }) // Clear journal_entry_id on transaction enqueue({ data: null, error: null }) - // logMatchEvent for storno - enqueue({ data: null, error: null }) + // (logMatchEvent does not consume a from() on the mocked route client) // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-payment' }) // Update invoice (optimistic lock) @@ -629,6 +632,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial' }) @@ -678,6 +682,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments @@ -723,6 +728,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', @@ -755,6 +761,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // No hard-duplicate check here: it only runs for 'sent'/'overdue', so the // next query is the settings fetch. enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', @@ -793,6 +800,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) @@ -951,8 +959,10 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - // No cash_accounts enqueue: resolveSettlementAccount short-circuits to - // '1930' when cash_account_id is null, with no DB call. + // With cash_account_id null, resolveSettlementAccount lists the + // company's enabled cash accounts for the currency (issue #1722); no + // rows here, so it keeps the 1930 fallback. + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts mockCreateJournalEntry.mockResolvedValue({ id: 'je-default' }) @@ -1074,6 +1084,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) @@ -1116,6 +1127,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Optimistic lock returns 0 rows (another request fully paid it) @@ -1145,6 +1157,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Optimistic lock succeeds @@ -1176,6 +1189,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // Nothing else enqueued on purpose: the route must return before the // invoice update, payment insert, or transaction link ever run. @@ -1195,9 +1209,10 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // The raw English message never reaches the user (issue #337): the reason // detail carries the Swedish invoice-context fallback. expect(body.error.details?.reason).toBe('Kunde inte hantera fakturan. Försök igen.') - // Exactly the four reads happened (tx, invoice, hard-dup, settings): - // no invoice update, no invoice_payments insert, no transaction link. - expect(mockSupabase.from).toHaveBeenCalledTimes(4) + // Exactly the five reads happened (tx, invoice, hard-dup, settings, + // settlement-account listing): no invoice update, no invoice_payments + // insert, no transaction link. + expect(mockSupabase.from).toHaveBeenCalledTimes(5) }) it('aborts the match when createJournalEntry resolves without an id (no half-state)', async () => { @@ -1208,6 +1223,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue(null) @@ -1220,7 +1236,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(500) expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED') - expect(mockSupabase.from).toHaveBeenCalledTimes(4) + expect(mockSupabase.from).toHaveBeenCalledTimes(5) }) // ──────────────────────────────────────────────────────────────── @@ -1268,6 +1284,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) // Hard-duplicate check is skipped for partially_paid; jump straight to settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice @@ -1347,6 +1364,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { }) enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 mockCreateJournalEntry.mockResolvedValue({ id: 'je-forced' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts index 9dcd242b..329a3846 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts @@ -143,9 +143,13 @@ function enqueueHappyPath(opts: { }) // 3. company_settings fetch enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null }) - // 4. cash_accounts lookup (only when the transaction is linked to one) + // 4. cash_accounts lookup: by id when the transaction is linked to one, + // otherwise the currency-fallback listing (issue #1722), empty here so the + // 1930 fallback applies. if (opts.transaction.cash_account_id) { enqueue({ data: { ledger_account: opts.cashAccountLedger ?? '1930' }, error: null }) + } else { + enqueue({ data: [], error: null }) } // 5. supplier_invoices update (CAS) enqueue({ data: [{ id: SI_UUID }], error: null }) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index 549d3ddc..4d32f325 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -255,6 +255,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string ctx.companyId!, transaction.cash_account_id, txLog, + transaction.currency, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 9413d1f2..a20678ec 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -212,6 +212,7 @@ async function categorizeOne( companyId, transaction.cash_account_id, log, + transaction.currency, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) } catch (err) { diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index dbba86f3..60c1ccf9 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -2651,6 +2651,7 @@ export const invoiceInboxExtension: Extension = { ctx.companyId, (tx as Transaction).cash_account_id, createLogger('invoice-inbox.suggest-booking'), + (tx as Transaction).currency, ) // evaluateMappingRules applies the settlement account itself on every // return path. Applying it again rewrote a legitimate 1930 leg, which diff --git a/extensions/general/mcp-server/__tests__/categorize-account-override.test.ts b/extensions/general/mcp-server/__tests__/categorize-account-override.test.ts index 55ae50a4..3eec2f18 100644 --- a/extensions/general/mcp-server/__tests__/categorize-account-override.test.ts +++ b/extensions/general/mcp-server/__tests__/categorize-account-override.test.ts @@ -92,6 +92,7 @@ describe('gnubok_categorize_transaction: account_override', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: coreTxRow() }) // core: transactions enqueue({ data: settingsRow }) // core: company_settings + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: null }) // applyAccountOverride: chart_of_accounts miss await expect( @@ -108,6 +109,7 @@ describe('gnubok_categorize_transaction: account_override', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: coreTxRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { account_number: '4020', account_class: 4, is_active: false } }) await expect( @@ -124,6 +126,7 @@ describe('gnubok_categorize_transaction: account_override', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: coreTxRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 await expect( categorize.execute( @@ -139,6 +142,7 @@ describe('gnubok_categorize_transaction: account_override', () => { const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: coreTxRow() }) // core: transactions enqueue({ data: settingsRow }) // core: company_settings + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) // override chart hit enqueue({ data: guardTxRow() }) // tool: transactions re-fetch enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings diff --git a/extensions/general/mcp-server/__tests__/categorize-category-required.test.ts b/extensions/general/mcp-server/__tests__/categorize-category-required.test.ts index b7f301bc..0fcc5ac1 100644 --- a/extensions/general/mcp-server/__tests__/categorize-category-required.test.ts +++ b/extensions/general/mcp-server/__tests__/categorize-category-required.test.ts @@ -120,6 +120,7 @@ describe('gnubok_categorize_transaction: category presence guard (#1662)', () => const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: coreTxRow() }) // core: transactions enqueue({ data: settingsRow }) // core: company_settings + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: guardTxRow() }) // tool: transactions re-fetch enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods diff --git a/extensions/general/mcp-server/__tests__/categorize-duplicate-message.test.ts b/extensions/general/mcp-server/__tests__/categorize-duplicate-message.test.ts index 8592e54c..d1626165 100644 --- a/extensions/general/mcp-server/__tests__/categorize-duplicate-message.test.ts +++ b/extensions/general/mcp-server/__tests__/categorize-duplicate-message.test.ts @@ -102,6 +102,7 @@ describe('gnubok_categorize_transaction duplicate-guard message', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: coreTxRow(), error: null }) // core: transactions enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // core: settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: guardTxRow(), error: null }) // tool: transactions re-fetch mockDetectDup.mockResolvedValue( @@ -127,6 +128,7 @@ describe('gnubok_categorize_transaction duplicate-guard message', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: coreTxRow({ amount: -1616, currency: 'SEK' }), error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: guardTxRow({ amount: -1616, currency: 'SEK' }), error: null }) mockDetectDup.mockResolvedValue(candidate({ amount: -1616 })) @@ -143,6 +145,7 @@ describe('gnubok_categorize_transaction duplicate-guard message', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: coreTxRow({ amount: -8570.87 }), error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: guardTxRow({ amount: -8570.87 }), error: null }) mockDetectDup.mockResolvedValue( diff --git a/extensions/general/mcp-server/__tests__/dimension-tools.test.ts b/extensions/general/mcp-server/__tests__/dimension-tools.test.ts index d43f0792..7bdf6ed0 100644 --- a/extensions/general/mcp-server/__tests__/dimension-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/dimension-tools.test.ts @@ -731,7 +731,7 @@ describe('gnubok_categorize_transaction: dimensions bag', () => { }) enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) - enqueue({ data: { ledger_account: '1931' }, error: null }) + enqueue({ data: { ledger_account: '1931' }, error: null }) // resolveSettlementAccount: explicit cash_account_id lookup enqueue({ data: tx, error: null }) enqueue({ data: null, error: null }) enqueue({ data: null, error: null }) @@ -764,6 +764,7 @@ describe('gnubok_categorize_transaction: dimensions bag', () => { // categorizeTransactionCore: transaction fetch + company_settings enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 // transaction fetch for the title enqueue({ data: tx, error: null }) // resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values @@ -820,6 +821,7 @@ describe('gnubok_categorize_transaction: dimensions bag', () => { const tx = makeTransaction({ id: 'tx-1', amount: -500 }) enqueue({ data: tx, error: null }) enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: tx, error: null }) enqueue({ data: null, error: null }) // period status layer 1 enqueue({ data: null, error: null }) // period status layer 2 diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index b4e6541b..b5b22523 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -361,6 +361,7 @@ describe('MCP Receipt Matcher', () => { enqueueMany([ { data: tx, error: null }, // fetch transaction (preview) { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + { data: [], error: null }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: tx, error: null }, // fetch transaction for title { data: null, error: null }, // resolvePeriodStatusForDate: company_settings { data: null, error: null }, // resolvePeriodStatusForDate: fiscal_periods @@ -404,6 +405,7 @@ describe('MCP Receipt Matcher', () => { enqueueMany([ { data: tx, error: null }, { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + { data: [], error: null }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: tx, error: null }, // fetch transaction for title { data: null, error: null }, // resolvePeriodStatusForDate: company_settings { data: null, error: null }, // resolvePeriodStatusForDate: fiscal_periods diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 19f7e11a..dcaaff2d 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -1141,6 +1141,7 @@ async function categorizeTransactionCore( companyId, transaction.cash_account_id, log, + transaction.currency, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) diff --git a/lib/bookkeeping/__tests__/settlement-account.test.ts b/lib/bookkeeping/__tests__/settlement-account.test.ts index f0a590b3..f256f76d 100644 --- a/lib/bookkeeping/__tests__/settlement-account.test.ts +++ b/lib/bookkeeping/__tests__/settlement-account.test.ts @@ -1,18 +1,109 @@ import { describe, it, expect, vi } from 'vitest' -import { createMockSupabase } from '@/tests/helpers' +import { createMockSupabase, createQueuedMockSupabase } from '@/tests/helpers' import { resolveSettlementAccount } from '../settlement-account' import { BookkeepingDatabaseError } from '../errors' const noopLog = { warn: vi.fn() } as unknown as import('@/lib/logger').Logger describe('resolveSettlementAccount', () => { - it('returns 1930 when the transaction has no cash_account_id', async () => { - const { supabase } = createMockSupabase() + describe('no cash_account_id: single-enabled-account currency fallback (#1722)', () => { + it('resolves the single enabled account for the currency instead of 1930', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: [{ ledger_account: '1920' }], error: null }) - const result = await resolveSettlementAccount(supabase as never, 'company-1', null, noopLog) + const result = await resolveSettlementAccount(supabase as never, 'company-1', null, noopLog) - expect(result).toBe('1930') - expect(supabase.from).not.toHaveBeenCalled() + expect(result).toBe('1920') + expect(supabase.from).toHaveBeenCalledWith('cash_accounts') + // The candidate listing must be narrowed to enabled accounts in the + // transaction's currency (SEK by default), mirroring the client-side + // resolveAccount semantics. + const eqArgs = findCalls('cash_accounts', 'eq') + expect(eqArgs).toContainEqual(['company_id', 'company-1']) + expect(eqArgs).toContainEqual(['enabled', true]) + expect(eqArgs).toContainEqual(['currency', 'SEK']) + }) + + it('filters candidates by the currency argument', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: [{ ledger_account: '1939' }], error: null }) + + const result = await resolveSettlementAccount( + supabase as never, + 'company-1', + null, + noopLog, + 'EUR', + ) + + expect(result).toBe('1939') + expect(findCalls('cash_accounts', 'eq')).toContainEqual(['currency', 'EUR']) + }) + + it('keeps the 1930 fallback when the company has no enabled account in the currency', async () => { + // A SEK transaction in a company whose only enabled cash account is EUR: + // the currency-narrowed listing comes back empty. + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: [], error: null }) + + const result = await resolveSettlementAccount( + supabase as never, + 'company-1', + null, + noopLog, + 'SEK', + ) + + expect(result).toBe('1930') + expect(findCalls('cash_accounts', 'eq')).toContainEqual(['currency', 'SEK']) + }) + + it('keeps the 1930 fallback when several enabled accounts share the currency', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [{ ledger_account: '1920' }, { ledger_account: '1930' }], + error: null, + }) + + const result = await resolveSettlementAccount(supabase as never, 'company-1', null, noopLog) + + expect(result).toBe('1930') + }) + + it('warns and keeps the 1930 fallback when the candidate lookup errors', async () => { + // Unlike the explicit-cashAccountId branch (#842), this branch never + // queried before, so an infra error degrades to the historical fallback + // instead of failing the request. + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'boom' } }) + const warn = vi.fn() + + const result = await resolveSettlementAccount(supabase as never, 'company-1', null, { + warn, + } as unknown as import('@/lib/logger').Logger) + + expect(result).toBe('1930') + expect(warn).toHaveBeenCalledWith( + 'settlement-account currency fallback lookup failed; defaulting to 1930', + expect.objectContaining({ companyId: 'company-1', currency: 'SEK' }), + ) + }) + + it('warns and keeps the 1930 fallback when the single row has no ledger_account', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [{ ledger_account: null }], error: null }) + const warn = vi.fn() + + const result = await resolveSettlementAccount(supabase as never, 'company-1', null, { + warn, + } as unknown as import('@/lib/logger').Logger) + + expect(result).toBe('1930') + expect(warn).toHaveBeenCalledWith( + 'settlement-account currency fallback row has no ledger_account; defaulting to 1930', + expect.objectContaining({ companyId: 'company-1', currency: 'SEK' }), + ) + }) }) it('returns the linked cash account ledger_account', async () => { diff --git a/lib/bookkeeping/settlement-account.ts b/lib/bookkeeping/settlement-account.ts index db1c2082..8afa48d7 100644 --- a/lib/bookkeeping/settlement-account.ts +++ b/lib/bookkeeping/settlement-account.ts @@ -13,14 +13,59 @@ const FALLBACK_ACCOUNT = '1930' * to which bank account a specific transaction is linked to. * cash_account_id -> cash_accounts.ledger_account is the only source of * truth for a real transaction's settlement account. + * + * When the transaction has NO cash_account_id (legacy/unresolved rows), + * mirror the client-side resolveAccount (lib/cash-accounts/resolve-account.ts): + * if the company has EXACTLY ONE enabled cash account in the transaction's + * currency, that account is unambiguous and the bank leg belongs there. + * Without this, a company whose only bank account is e.g. 1920 got its + * booking dialogs previewing 1920 while the posted verifikat silently hit + * the hardcoded 1930 template leg (issue #1722). Zero or several candidate + * accounts keeps the historical 1930 fallback: guessing between real + * accounts is worse than the known-neutral default. */ export async function resolveSettlementAccount( supabase: SupabaseClient, companyId: string, cashAccountId: string | null, log: Logger, + currency: string = 'SEK', ): Promise { - if (!cashAccountId) return FALLBACK_ACCOUNT + if (!cashAccountId) { + const { data: candidates, error: listError } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('company_id', companyId) + .eq('enabled', true) + .eq('currency', currency) + .limit(2) + + if (listError) { + // Unlike the explicit-cashAccountId branch below (which throws, #842), + // this path historically never queried at all and always returned 1930, + // so failing the whole request on a lookup error here would regress + // every unbound transaction, including ambiguous companies whose answer + // is 1930 anyway. Degrade to the historical fallback and warn. + log.warn('settlement-account currency fallback lookup failed; defaulting to 1930', { + companyId, + currency, + error: listError.message, + }) + return FALLBACK_ACCOUNT + } + + if (candidates?.length === 1) { + const ledgerAccount = candidates[0]?.ledger_account as string | null + if (ledgerAccount) return ledgerAccount + // ledger_account is NOT NULL in the schema; a hole here is a + // data-integrity gap that must not hide behind a plausible 1930 leg. + log.warn('settlement-account currency fallback row has no ledger_account; defaulting to 1930', { + companyId, + currency, + }) + } + return FALLBACK_ACCOUNT + } const { data, error } = await supabase .from('cash_accounts') diff --git a/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts b/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts index f9975b5d..e7a67728 100644 --- a/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts +++ b/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts @@ -124,6 +124,7 @@ describe('commit duplicate guard: categorize_transaction (reverse / book the ban { data: [] }, // pre-FY guard: earliest-period lookup (none yet) { data: null }, // fiscal-period upsert { data: null }, // journal-entry period lookup: partial categorization path + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [] }, // pre-FY clamp: earliest-period lookup (none yet) { data: [{ diff --git a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts index cc733e4b..c2c2d80b 100644 --- a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts +++ b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts @@ -251,6 +251,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r error: null, }) // invoice fetch enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update enqueue({ data: null, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) @@ -296,8 +297,10 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r error: null, }) // invoice fetch enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings - // No cash_accounts enqueue: resolveSettlementAccount short-circuits to - // '1930' when cash_account_id is null, with no DB call. + // With cash_account_id null, resolveSettlementAccount lists the company's + // enabled cash accounts for the currency (issue #1722); no rows here, so + // it keeps the 1930 fallback. + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update enqueue({ data: null, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) @@ -402,6 +405,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r error: null, }) // invoice fetch enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update enqueue({ data: null, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) @@ -468,6 +472,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r error: null, }) // invoice fetch enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update enqueue({ data: null, error: null }) // invoice_payments insert enqueue({ data: null, error: null }) // transactions update (link) diff --git a/lib/transactions/__tests__/categorize-core.bulk.test.ts b/lib/transactions/__tests__/categorize-core.bulk.test.ts index f2b0ca3f..fb9b4576 100644 --- a/lib/transactions/__tests__/categorize-core.bulk.test.ts +++ b/lib/transactions/__tests__/categorize-core.bulk.test.ts @@ -231,6 +231,7 @@ describe('bulkBookMatchedInboxItems: booking', () => { { data: { id: 'tx-1', date: '2026-06-01', amount: -700.28, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, // 3. company_settings { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 // 4. ensureFiscalPeriod → existing period { data: [{ id: 'fp-1' }] }, // 5. transactions update (mark booked) @@ -273,7 +274,7 @@ describe('bulkBookMatchedInboxItems: booking', () => { }, }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, - { data: { ledger_account: '1931' } }, + { data: { ledger_account: '1931' } }, // resolveSettlementAccount: explicit cash_account_id lookup { data: [{ id: 'fp-1' }] }, { data: [{ id: 'tx-1' }], error: null }, { data: [] }, @@ -314,6 +315,7 @@ describe('bulkBookMatchedInboxItems: booking', () => { { data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } }, { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { data: [{ id: 'tx-1' }], error: null }, { data: [] }, @@ -345,6 +347,7 @@ describe('bulkBookMatchedInboxItems: booking', () => { { data: { id: 'i2', matched_transaction_id: 'tx-2', created_journal_entry_id: null, created_supplier_invoice_id: null } }, { data: { id: 'tx-2', date: '2026-06-02', amount: -25, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { data: [{ id: 'tx-2' }], error: null }, { data: [] }, @@ -381,6 +384,7 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading', { data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null, channel_context: WA_CONTEXT } }, { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { data: [{ id: 'tx-1' }], error: null }, { data: [] }, @@ -447,6 +451,7 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading', }, { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { error: null }, { data: [] }, @@ -483,6 +488,7 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading', }, { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { error: null }, { data: [] }, @@ -508,6 +514,7 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading', { data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null, channel_context: null } }, { data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { error: null }, { data: [] }, @@ -536,6 +543,7 @@ describe('bulkBookMatchedInboxItems: intra-batch duplicate handling', () => { { data: { id: itemId, matched_transaction_id: txId, created_journal_entry_id: null, created_supplier_invoice_id: null } }, { data: { id: txId, date: '2026-06-01', amount, currency: 'SEK', cash_account_id: null, journal_entry_id: null } }, { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930 { data: [{ id: 'fp-1' }] }, { data: [{ id: txId }], error: null }, { data: { document_id: null } }, // propagation: tx pin lookup diff --git a/lib/transactions/__tests__/categorize-core.override.test.ts b/lib/transactions/__tests__/categorize-core.override.test.ts index bda14285..da3dd625 100644 --- a/lib/transactions/__tests__/categorize-core.override.test.ts +++ b/lib/transactions/__tests__/categorize-core.override.test.ts @@ -71,6 +71,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { eventBus.on('transaction.categorized', categorizedHandler) enqueue({ data: txRow({ is_ignored: true }) }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'fp-1' }] }) enqueue({ data: [txRow({ @@ -109,6 +110,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: txRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'fp-1' }] }) enqueue({ data: [] }) mockCreateJE.mockResolvedValueOnce(null) @@ -129,6 +131,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: txRow() }) // transactions select enqueue({ data: settingsRow }) // company_settings + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) // override chart hit enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod: open period exists enqueue({ data: [{ id: TX_ID }] }) // transactions update @@ -153,6 +156,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: txRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod enqueue({ data: [{ id: TX_ID }] }) // transactions update @@ -177,6 +181,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: txRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: { account_number: '4020', account_class: 4, is_active: false } }) const result = await categorizeMatchedTransaction( @@ -193,6 +198,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: txRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 const result = await categorizeMatchedTransaction( supabase as never, 'user-1', 'company-1', TX_ID, @@ -208,6 +214,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: txRow({ is_ignored: true }) }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'fp-1' }] }) enqueue({ data: null, @@ -240,6 +247,7 @@ describe('categorizeMatchedTransaction: accountOverride', () => { const { supabase, enqueue, calls } = createQueuedMockSupabase() enqueue({ data: txRow() }) enqueue({ data: settingsRow }) + enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930 enqueue({ data: [{ id: 'fp-1' }] }) enqueue({ data: [] }) diff --git a/lib/transactions/__tests__/categorize-core.settlement-fallback.test.ts b/lib/transactions/__tests__/categorize-core.settlement-fallback.test.ts new file mode 100644 index 00000000..c263d2b1 --- /dev/null +++ b/lib/transactions/__tests__/categorize-core.settlement-fallback.test.ts @@ -0,0 +1,118 @@ +/** + * categorizeMatchedTransaction: settlement-account currency fallback (#1722). + * + * A transaction with NO cash_account_id (legacy/unresolved rows) used to book + * its bank leg on the hardcoded 1930 from the category/template mapping even + * when the company's only bank account is e.g. 1920 (PlusGiro), while the + * booking dialogs previewed 1920 via the client-side resolveAccount. The + * server resolver now mirrors that fallback: exactly one enabled cash account + * in the transaction's currency wins; anything ambiguous keeps 1930. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const mockCreateJE = vi.fn() +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args), +})) +vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ + reverseOrphanedJournalEntry: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ + detectBookingDuplicate: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/transactions/inbox-underlag', () => ({ + propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ + upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/transactions/link-journal-entry', () => ({ + hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false), +})) +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: vi.fn().mockResolvedValue(undefined), +})) + +import { categorizeMatchedTransaction } from '../categorize-core' + +const TX_ID = '00000000-0000-4000-8000-0000000000dd' + +const txRow = (over: Record = {}) => ({ + id: TX_ID, + company_id: 'company-1', + date: '2026-07-10', + amount: -479, + currency: 'SEK', + amount_sek: -479, + exchange_rate: 1, + description: 'KONTORSMATERIAL', + merchant_name: null, + cash_account_id: null, + document_id: null, + journal_entry_id: null, + ...over, +}) + +const settingsRow = { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockCreateJE.mockResolvedValue({ id: 'je-settle-1' }) +}) + +describe('categorizeMatchedTransaction: single-account currency fallback (#1722)', () => { + it('books the bank leg on the single enabled cash account, not the hardcoded 1930', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: txRow() }) // transactions select + enqueue({ data: settingsRow }) // company_settings + // The company's ONLY enabled SEK cash account books on 1920. + enqueue({ data: [{ ledger_account: '1920' }] }) // resolveSettlementAccount currency fallback + enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod + enqueue({ data: [{ id: TX_ID }] }) // transactions update (CAS) + + const result = await categorizeMatchedTransaction( + supabase as never, + 'user-1', + 'company-1', + TX_ID, + { category: 'expense_office' }, + ) + + expect(result.error).toBeUndefined() + expect(result.data?.journal_entry_id).toBe('je-settle-1') + // 5th arg of createTransactionJournalEntry is the mapping result: its + // 1930 leg was rewritten to the company's real settlement account. + const mappingArg = mockCreateJE.mock.calls[0][4] as { credit_account: string } + expect(mappingArg.credit_account).toBe('1920') + // The fallback listing was narrowed to enabled accounts in the + // transaction's currency. + const eqArgs = findCalls('cash_accounts', 'eq') + expect(eqArgs).toContainEqual(['enabled', true]) + expect(eqArgs).toContainEqual(['currency', 'SEK']) + }) + + it('keeps 1930 when the company has several enabled accounts in the currency', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow() }) + enqueue({ data: settingsRow }) + // Two enabled SEK accounts: ambiguous, so no guessing. + enqueue({ data: [{ ledger_account: '1920' }, { ledger_account: '1930' }] }) + enqueue({ data: [{ id: 'fp-1' }] }) + enqueue({ data: [{ id: TX_ID }] }) + + const result = await categorizeMatchedTransaction( + supabase as never, + 'user-1', + 'company-1', + TX_ID, + { category: 'expense_office' }, + ) + + expect(result.error).toBeUndefined() + const mappingArg = mockCreateJE.mock.calls[0][4] as { credit_account: string } + expect(mappingArg.credit_account).toBe('1930') + }) +}) diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index 80a8d71a..5f70ae54 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -363,6 +363,7 @@ export async function categorizeMatchedTransaction( companyId, transaction.cash_account_id, log, + transaction.currency, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) // Re-validated here (not only at staging): the account can be deactivated