fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow (#717)

* fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow

The categorize flow always derived VAT as rate × gross/(1+rate) from the
transaction amount, with no way to use the underlag's actual moms. On e.g.
a restaurant receipt with dricks (no VAT on the tip), the agent could see
the document's correct VAT but the staged booking recomputed the wrong
rate-based amount on every attempt.

- buildMappingResultFromCategory: optional vatAmountOverride replaces the
  rate-derived VAT line ("Ingående/Utgående moms (enligt underlag)"; 0 =
  no VAT line). Rejects negatives, amounts above the 25%-extraction bound,
  and combination with reverse_charge / VAT-less treatments / private.
- gnubok_categorize_transaction: new vat_amount input, threaded into the
  staged preview and persisted in the operation params.
- commitCategorizeTransaction: reads params.vat_amount so the approved
  posting matches the staged preview exactly.
- PATCH /api/pending-operations/[id]: accepts vat_amount (null clears);
  preserves a staged override across category edits while the treatment
  still carries rate-based VAT, drops it when it no longer does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: guard order + agent guidance on vat_amount (PR #717 bots)

- Check treatment compatibility before the 25%-extraction bound so an
  oversized override on reverse_charge reports the actual mistake (the
  treatment), not the amount. Document why the typeof re-check stays:
  commit-time params come from jsonb, so TS types don't hold at runtime.
- vat_amount property description now warns that foreign VAT is never
  deductible as ingående moms and that a 0-moms document should use
  vat_treatment="exempt" rather than vat_amount=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): tools/list payload budget + reject vat_amount 0

core-only failed: the verbose vat_amount descriptions pushed the projected
tools/list payload to 36,051 tokens (ceiling 36,000; main is at 35,862).
Per the guard's own guidance, trim descriptions instead of bumping:
now 35,943.

Folds in the Swedish review's round-2 point while trimming: vat_amount 0
is now rejected with a pointer to vat_treatment "exempt". A 0-moms
document is an exempt supply — "exempt" produces the identical expense
booking and the correct income account (3004), so 0 had no use case and
only created a silent momsdeklaration misclassification path. Schema
declares exclusiveMinimum: 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): use roundOre for vat_amount math (antipattern ratchet)

Second core-only failure: the naive-ore-round ratchet caught the new
Math.round(x*100)/100 lines (662 > baseline 661). Switch the override
path to roundOre from lib/money — including the pre-existing computed-VAT
line this PR touched — and ratchet the baseline down (659, raw-route-auth
168 locked in from main-side fixes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-12 11:42:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f548b04aed
commit 8e8b63a200
8 changed files with 412 additions and 22 deletions
+13 -2
View File
@@ -497,6 +497,9 @@ async function categorizeTransactionCore(
txId: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
// 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,
userId: string,
companyId: string,
supabase: SupabaseClient,
@@ -619,7 +622,8 @@ async function categorizeTransactionCore(
transaction as Transaction,
isBusiness,
entityType,
vatTreatment
vatTreatment,
vatAmount
)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
@@ -2549,7 +2553,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
title: 'Categorize Bank Transaction',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. If an underlag is attached it rejects vat_treatment="reverse_charge" when the seller already charged VAT.',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides the computed moms; reverse_charge is rejected when the underlag shows the seller already charged VAT.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -2557,6 +2561,7 @@ export const tools: McpTool[] = [
transaction_id: { type: 'string', description: 'UUID of the transaction to categorize' },
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".' },
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.' },
},
required: ['transaction_id', 'category'],
@@ -2569,11 +2574,16 @@ export const tools: McpTool[] = [
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const vatAmount = typeof args.vat_amount === 'number' && Number.isFinite(args.vat_amount)
? args.vat_amount
: undefined
// 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,
userId,
companyId,
supabase,
@@ -2605,6 +2615,7 @@ export const tools: McpTool[] = [
transaction_id: args.transaction_id,
category: args.category,
vat_treatment: args.vat_treatment || null,
vat_amount: vatAmount ?? null,
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
? (args.notes as string).trim()
: null,