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