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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
8e3015e541
commit
7cf15a105f
@@ -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 () => {
|
||||
|
||||
@@ -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<string> {
|
||||
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')
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user