Files
accounted/extensions/general/mcp-server/__tests__/categorize-account-override.test.ts
T
7cf15a105f 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>
2026-08-24 13:26:08 +02:00

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')
})
})