* 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>
180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
/**
|
|
* gnubok_categorize_transaction: the account_override parameter.
|
|
*
|
|
* Custom accounts (e.g. VMB accounts like 4020) are outside the fixed
|
|
* category → account maps, so account_override is the ONLY path by which the
|
|
* MCP categorize surface can book on them. These tests pin:
|
|
* - staging-time validation (unknown / inactive / malformed / private),
|
|
* - the override landing in both the staged params (for the commit
|
|
* executor) and the preview (for the approver),
|
|
* Companion suites:
|
|
* - lib/bookkeeping/__tests__/account-override.test.ts (the shared helper)
|
|
* - lib/transactions/__tests__/categorize-core.override.test.ts (commit path)
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
const mockDetectDup = vi.fn()
|
|
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
|
|
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
|
|
}))
|
|
|
|
import { tools } from '../server'
|
|
|
|
const categorize = tools.find((t) => t.name === 'gnubok_categorize_transaction')!
|
|
|
|
const TX_ID = '00000000-0000-4000-8000-0000000000bb'
|
|
|
|
/** `transactions` row for categorizeTransactionCore's select('*'). Synthetic. */
|
|
const coreTxRow = (over: Record<string, unknown> = {}) => ({
|
|
id: TX_ID,
|
|
date: '2026-07-10',
|
|
amount: -479,
|
|
currency: 'SEK',
|
|
amount_sek: -479,
|
|
exchange_rate: 1,
|
|
description: 'SECOND HAND BUTIK',
|
|
merchant_name: null,
|
|
cash_account_id: null,
|
|
document_id: null,
|
|
journal_entry_id: null,
|
|
is_business: true,
|
|
...over,
|
|
})
|
|
|
|
/** The narrower projection the tool re-fetches for the guard + title. */
|
|
const guardTxRow = (over: Record<string, unknown> = {}) => ({
|
|
description: 'SECOND HAND BUTIK',
|
|
merchant_name: null,
|
|
amount: -479,
|
|
currency: 'SEK',
|
|
amount_sek: -479,
|
|
exchange_rate: 1,
|
|
date: '2026-07-10',
|
|
cash_account_id: null,
|
|
...over,
|
|
})
|
|
|
|
const settingsRow = { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
mockDetectDup.mockResolvedValue(null)
|
|
})
|
|
|
|
describe('gnubok_categorize_transaction: account_override', () => {
|
|
it('declares account_override in the input schema with a 4-digit pattern', () => {
|
|
const schema = categorize.inputSchema as {
|
|
properties: { account_override?: { type: string; pattern?: string } }
|
|
required?: string[]
|
|
}
|
|
expect(schema.properties.account_override).toBeDefined()
|
|
expect(schema.properties.account_override?.type).toBe('string')
|
|
expect(schema.properties.account_override?.pattern).toBe('^\\d{4}$')
|
|
expect(schema.required ?? []).not.toContain('account_override')
|
|
})
|
|
|
|
it('rejects a malformed override before any DB work', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
categorize.execute(
|
|
{ transaction_id: TX_ID, category: 'expense_other', account_override: '40a0' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toThrow(/4 digits/)
|
|
})
|
|
|
|
it('rejects at staging when the override account is not in the chart', async () => {
|
|
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(
|
|
categorize.execute(
|
|
{ transaction_id: TX_ID, category: 'expense_other', account_override: '4020' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toThrow(/finns inte i kontoplanen/)
|
|
})
|
|
|
|
it('rejects at staging when the override account is inactive', async () => {
|
|
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(
|
|
categorize.execute(
|
|
{ transaction_id: TX_ID, category: 'expense_other', account_override: '4020' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toThrow(/inaktivt/)
|
|
})
|
|
|
|
it('rejects account_override combined with category "private"', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: coreTxRow() })
|
|
enqueue({ data: settingsRow })
|
|
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
|
|
|
await expect(
|
|
categorize.execute(
|
|
{ transaction_id: TX_ID, category: 'private', account_override: '4020' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toThrow(/private/)
|
|
})
|
|
|
|
it('stages with the override in params AND preview when the account is active', async () => {
|
|
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
|
|
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
|
|
enqueue({ data: { id: 'op-override-1' } }) // pending_operations insert
|
|
|
|
const result = (await categorize.execute(
|
|
{
|
|
transaction_id: TX_ID,
|
|
category: 'expense_other',
|
|
vat_treatment: 'exempt',
|
|
account_override: '4020',
|
|
},
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
{ type: 'api_key' },
|
|
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
|
|
|
|
expect(result.staged).toBe(true)
|
|
expect(result.operation_id).toBe('op-override-1')
|
|
// The preview must show the account that will actually be posted, not the
|
|
// category default (6991 for expense_other).
|
|
expect(result.preview.debit_account).toBe('4020')
|
|
expect(result.preview.account_override).toBe('4020')
|
|
|
|
// The staged params must carry the override so the commit executor books
|
|
// on it after approval.
|
|
const insertArgs = findCall('pending_operations', 'insert')
|
|
expect(insertArgs).toBeDefined()
|
|
const payload = (insertArgs as unknown[])[0] as { params?: { account_override?: string | null } }
|
|
expect(payload.params?.account_override).toBe('4020')
|
|
})
|
|
})
|