* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link gnubok_categorize_transaction only spoke a 19-category enum mapping to 21 hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were unreachable from the agent surface even when active in the chart. - add account_override to gnubok_categorize_transaction with v1 REST semantics via a shared helper (lib/bookkeeping/account-override.ts): business-side replacement, class-2 auto-VAT drop with the 2610-2649 moms-line exception, plus a same-account degenerate guard; validated at staging and re-validated at commit - align the gnubok_create_voucher staging gate with the engine's seeding semantics: BAS 2026 accounts merely absent from the chart pass (the engine backfills them at commit) and the preview lists will_activate_accounts with BAS-name fallback; non-BAS unknown and inactive accounts still rejected - stop suggest_categories silently dropping mapping rules whose account is outside the fixed category maps; they surface with the rule's own account and an explanatory match_reason - correct the create_account next-step hint (categorize could never use the new account before; now true via account_override) - point the settings "Kontoplan (BAS)" link at /chart-of-accounts and redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in #850; the deep link never worked after the #854 merge collision) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): address review findings on account_override - commit executor rejects a present-but-malformed stored account_override loudly instead of degrading to the category default (CodeRabbit major; the approver approved a preview showing the override account); with commitPendingOperation regression tests - accountToCategory returns null for unknown income accounts so custom income accounts get the same diagnostic as expenses (CodeRabbit minor), with income + reason-accumulation tests (CodeRabbit nit) - pin the class-2 VAT-drop balance invariant with a test through buildTransactionEntryLines (Swedish compliance review: gross booking, never an unbalanced net + missing VAT leg) - account_override description asks the agent to state the actual affärshändelse in notes when overriding (BFL 5 kap description concern) - eventBus.clear() in the two new test suites (CodeRabbit minor) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent Round-2 Swedish compliance finding: the class-2 VAT drop did not cover margin-scheme (VMB) accounts in class 3/4, which are the override's flagship use case, so a forgotten vat_treatment attached the category default standard_25 and booked an ingående-moms deduction on a transaction where input VAT is not deductible (ML 2023:200). applyAccountOverride now takes explicit VAT intent (vat_treatment or vat_amount present) and books GROSS with no auto-VAT line without it: forgetting the flag under-deducts (lawful), never over-deducts. Both call sites (MCP staging preview, commit core) derive the flag the same way; the tool description states the enforced behavior. Deliberate divergence from v1 REST recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: move stray decision-log entry to the root DECISIONS.md The round-2 entry was appended from the wrong working directory and landed as lib/bookkeeping/__tests__/DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
176 lines
6.3 KiB
TypeScript
176 lines
6.3 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: 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: { 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 })
|
|
|
|
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: { 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')
|
|
})
|
|
})
|