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
+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 } : {}),