feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)

* 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>
This commit is contained in:
Mattsson
2026-08-14 01:24:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4bb0655e4a
commit fbe4e18730
14 changed files with 915 additions and 24 deletions
@@ -0,0 +1,175 @@
/**
* 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')
})
})
@@ -142,7 +142,7 @@ describe('gnubok_create_voucher: staging gates', () => {
).rejects.toThrow(/utanför/i)
})
it('rejects when a referenced account is missing from the chart', async () => {
it('rejects an account that is neither in the chart nor in BAS 2026', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
@@ -154,22 +154,81 @@ describe('gnubok_create_voucher: staging gates', () => {
},
error: null,
})
// chart_of_accounts returns nothing: both accounts unknown
enqueue({ data: [], error: null })
// chart_of_accounts knows only 1930: 4020 is a custom number the company
// never created, and it is absent from the BAS 2026 catalog, so the
// engine could not seed it either.
enqueue({
data: [{ account_number: '1930', account_name: 'Företagskonto', is_active: true }],
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'unknown accounts',
description: 'custom account never created',
fiscal_period_id: 'fp-1',
lines: balancedLines,
lines: [
{ account_number: '4020', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/saknas i kontoplanen/i)
).rejects.toThrow(/saknas i kontoplanen och finns inte i BAS 2026: 4020/i)
})
it('stages when a BAS 2026 account is merely absent from the chart (engine seeds it)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'fp-1',
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
name: '2026',
},
error: null,
})
// 7690 (Övriga personalkostnader) is in BAS 2026 but not in this chart:
// the old gate rejected it even though createDraftEntry backfills it.
enqueue({
data: [{ account_number: '1930', account_name: 'Företagskonto', is_active: true }],
error: null,
})
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2
enqueue({ data: { id: 'op-seedable' }, error: null }) // pending_operations insert
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Friskvård badhus',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '7690', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
will_activate_accounts?: string[]
lines: Array<{ account_number: string; account_name: string | null }>
}
}
expect(result.staged).toBe(true)
// The approver must see the activation side-effect and a named line
// (name from the BAS catalog, not a bare number).
expect(result.preview.will_activate_accounts).toEqual(['7690'])
const line7690 = result.preview.lines.find((l) => l.account_number === '7690')
expect(line7690?.account_name).toMatch(/personalkostnader/i)
})
it('rejects when a referenced account exists but is inactive', async () => {
+56 -10
View File
@@ -18,6 +18,8 @@ import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { applyAccountOverride } from '@/lib/bookkeeping/account-override'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
@@ -824,6 +826,9 @@ async function categorizeTransactionCore(
// Underlag's actual VAT when it differs from rate × belopp (e.g. dricks on
// a restaurant receipt carries no moms). Replaces the computed VAT line.
vatAmount: number | undefined,
// Explicit business-side account replacing the category default (v1 REST
// account_override semantics): must exist active in chart_of_accounts.
accountOverride: string | undefined,
userId: string,
companyId: string,
supabase: SupabaseClient,
@@ -960,6 +965,22 @@ async function categorizeTransactionCore(
)
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
// Applied at staging so the agent gets the tight rejection (unknown or
// inactive account) BEFORE anything is queued for approval, and the staged
// preview shows the account that will actually be posted. The commit path
// (categorizeMatchedTransaction) re-validates independently.
if (accountOverride) {
if (!isBusiness) {
throw new Error('account_override kan inte kombineras med category "private".')
}
mappingResult = await applyAccountOverride(
supabase, companyId, accountOverride, transaction.amount, mappingResult,
// Explicit VAT intent: a stated treatment or an underlag vat_amount.
// Without it the override books gross (see applyAccountOverride).
vatTreatment != null || vatAmount != null,
)
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`No account mapping for category "${category}" with entity type "${entityType}". ` +
@@ -4172,7 +4193,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
title: 'Categorize Bank Transaction',
description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT. Commit via gnubok_approve_pending_operation.',
description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. account_override books the business side on any active kontoplan account (custom incl.). Commit via gnubok_approve_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4181,6 +4202,7 @@ export const tools: McpTool[] = [
category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] },
vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] },
vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp: e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only: foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' },
account_override: { type: 'string', pattern: '^\\d{4}$', description: 'Books the business side (debit when money goes out, credit when money comes in) on this kontoplan account instead of the category default: the ONLY way to reach company-custom accounts (e.g. VMB). Must exist and be active (gnubok_list_accounts; create via gnubok_create_account). VMB purchases/sales carry no deductible moms: use vat_treatment "exempt". Without an explicit vat_treatment (or vat_amount) the override books GROSS with no auto-VAT line: a moms leg is never guessed onto a custom account. Class-2 overrides outside 2610-2649 always drop auto-VAT. Not valid with category "private". State the actual affärshändelse in notes (BFL 5 kap).' },
notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' },
dimensions: {
type: 'object',
@@ -4208,12 +4230,20 @@ export const tools: McpTool[] = [
// categorization preview runs. Resolution happens right before staging.
const inputDimensions = parseDimensionsArg(args.dimensions, 'dimensions')
// Runtime guard (hosts don't always enforce inputSchema patterns).
const accountOverride =
args.account_override === undefined ? undefined : String(args.account_override).trim()
if (accountOverride !== undefined && !ACCOUNT_NUMBER_RE.test(accountOverride)) {
throw new Error('account_override must be exactly 4 digits, e.g. "4020".')
}
// Compute the preview (accounts, amounts, VAT lines)
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
vatAmount,
accountOverride,
userId,
companyId,
supabase,
@@ -4296,6 +4326,7 @@ export const tools: McpTool[] = [
category: args.category,
vat_treatment: args.vat_treatment || null,
vat_amount: vatAmount ?? null,
account_override: accountOverride ?? null,
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
? (args.notes as string).trim()
: null,
@@ -4305,6 +4336,7 @@ export const tools: McpTool[] = [
{
debit_account: result.debit_account,
credit_account: result.credit_account,
...(accountOverride ? { account_override: accountOverride } : {}),
amount: result.amount,
currency: result.currency,
// Exact journal lines the approval will post (net cost line, VAT
@@ -6457,7 +6489,7 @@ export const tools: McpTool[] = [
{ ...params, source: ref ? 'bas_2026' : 'custom' },
actor,
{
description: 'Once approved, the account is active and can carry voucher lines via gnubok_create_voucher or gnubok_categorize_transaction.',
description: 'Once approved, the account is active and bookable via gnubok_create_voucher, gnubok_bulk_book_transactions, or gnubok_categorize_transaction with account_override.',
tool: 'gnubok_list_accounts',
},
{
@@ -14542,9 +14574,13 @@ export const tools: McpTool[] = [
// Resolve account names for the preview so the approver reads
// "1010 Balanserade utgifter / 2440 Leverantörsskulder" rather than
// bare numbers. Also gate: refuse to stage when any line references an
// unknown or inactive account so the approver isn't shown a voucher
// that would fail at commit time anyway.
// bare numbers. Also gate: refuse to stage when a line references an
// account the ENGINE could not resolve either (same semantics as
// findUnresolvableAccounts): a BAS 2026 number merely absent from the
// chart passes, because createDraftEntry seeds it at commit; only
// numbers outside both the chart and the BAS catalog, plus rows a user
// deliberately deactivated (the backfill never resurrects those), are
// rejected here.
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
const { data: accounts } = await supabase
.from('chart_of_accounts')
@@ -14559,26 +14595,33 @@ export const tools: McpTool[] = [
})
}
const unknownAccounts = accountNumbers.filter((n) => !accountInfo.has(n))
const unseedableAccounts = unknownAccounts.filter((n) => !getBASReference(n))
const seedableAccounts = unknownAccounts.filter((n) => Boolean(getBASReference(n)))
const inactiveAccounts = accountNumbers.filter(
(n) => accountInfo.has(n) && !accountInfo.get(n)!.active,
)
if (unknownAccounts.length > 0 || inactiveAccounts.length > 0) {
if (unseedableAccounts.length > 0 || inactiveAccounts.length > 0) {
const parts: string[] = []
if (unknownAccounts.length > 0) {
parts.push(`saknas i kontoplanen: ${unknownAccounts.join(', ')}`)
if (unseedableAccounts.length > 0) {
parts.push(`saknas i kontoplanen och finns inte i BAS 2026: ${unseedableAccounts.join(', ')}`)
}
if (inactiveAccounts.length > 0) {
parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`)
}
throw new Error(
`Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` +
'Aktivera dem i kontoplanen eller välj andra konton.'
'Skapa kontot med gnubok_create_account, aktivera det med gnubok_update_account, eller välj andra konton.'
)
}
const previewLines = lines.map((l) => ({
account_number: l.account_number,
account_name: accountInfo.get(l.account_number)?.name ?? null,
// Fallback to the BAS catalog name for a seedable account that is not
// in the chart yet, so the approver still reads a named line.
account_name:
accountInfo.get(l.account_number)?.name ??
getBASReference(l.account_number)?.account_name ??
null,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
@@ -14648,6 +14691,9 @@ export const tools: McpTool[] = [
total_credit: balance.totalCredit,
line_count: lines.length,
lines: previewLines,
// BAS accounts not yet in the chart: the engine activates them at
// commit. Surfaced so the approver sees the side-effect up front.
...(seedableAccounts.length > 0 ? { will_activate_accounts: seedableAccounts } : {}),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),