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:
Jakob Wennberg
2026-08-24 13:26:08 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 8e3015e541
commit 7cf15a105f
24 changed files with 429 additions and 21 deletions
@@ -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
@@ -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
@@ -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
@@ -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(
@@ -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
@@ -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
+1
View File
@@ -1141,6 +1141,7 @@ async function categorizeTransactionCore(
companyId,
transaction.cash_account_id,
log,
transaction.currency,
)
mappingResult = applySettlementAccount(mappingResult, settlementAccount)