fix(vat): enforce decimal vat_rate on supplier invoice items and normalize MCP percent extraction (#1049)

Supplier invoice items store vat_rate as a decimal fraction (0.25) while
customer invoices use integer percent (25). The shared Zod schema accepted
0-100, so a percent-shaped vat_rate silently booked 2500 % VAT via
line_total * vat_rate, and the MCP inbox-conversion path staged the AI
extraction's percent-integer vatRate straight into the decimal column with
per-line vat_amount 0. Part of #310.

- CreateSupplierInvoiceItemSchema.vat_rate is now a literal union of the
  statutory decimal set (0, 0.06, 0.12, 0.25) with a unit-hint error,
  covering the cookie route, the invoice-inbox convert route, and /api/v1
  (whose runtime ALLOWED_SV_VAT_RATES guard stays as defense in depth).
- New shared normalizeVatRateToDecimal() in lib/vat: percent-shaped values
  (25, 12, 6) divide by 100, results snap to the legal Swedish set, and
  anything else (foreign 19/20, non-finite) maps to 0.
- gnubok_create_supplier_invoice_from_inbox normalizes vatRate at the
  extraction boundary and derives per-line vat_amount when the extraction
  carries none, so the staged header vat_amount is honest.
- The pending-operation executor normalizes staged vat_rate on insert, so
  rows staged before this fix cannot book percent-scaled VAT.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-17 13:28:36 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 03fd1b60b7
commit 5b8e3fa130
11 changed files with 302 additions and 7 deletions
@@ -526,6 +526,90 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
expect('dimensions' in params.items[0]).toBe(false)
})
it('normalizes percent vatRate from the real AI extraction shape and derives per-line vat_amount (issue #310)', async () => {
// Real ExtractionSchema output: camelCase keys, vatRate as a percent
// integer, and NO per-line VAT amount field. Staging must convert to the
// decimal convention of supplier_invoice_items (0.25) and derive the
// line's vat_amount, or the committed invoice books 2500 % VAT with a
// dishonest header vat_amount of 0.
const realExtractionShape = {
...baseExtracted,
totals: { subtotal: 1000, vatAmount: 250, total: 1250 },
lineItems: [
{ description: 'Konsulttimmar', quantity: 10, unitPrice: 100, lineTotal: 1000, vatRate: 25, accountSuggestion: null },
],
}
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-vat-1',
status: 'received',
extracted_data: realExtractionShape,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-vat-1',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-vat-1' },
'company-1', 'user-1', supabase,
)) as { staged: boolean }
expect(result.staged).toBe(true)
const params = inserts[0].params as {
vat_amount: number
items: Array<{ vat_rate: number; vat_amount: number; line_total: number }>
}
expect(params.items[0].vat_rate).toBe(0.25)
expect(params.items[0].vat_amount).toBe(250)
expect(params.items[0].line_total).toBe(1000)
// Header vat_amount is summed from the derived per-line amounts.
expect(params.vat_amount).toBe(250)
})
it('passes already-decimal vat_rate through unchanged and zeroes foreign percent rates', async () => {
const mixedRates = {
...baseExtracted,
lineItems: [
// Already decimal (staged by an agent following the storage convention).
{ description: 'Svensk tjänst', quantity: 1, unit_price: 1000, line_total: 1000, vat_rate: 0.12, vat_amount: 120 },
// Foreign rate (DE 19 %): outside the Swedish statutory set, maps to 0
// per the extraction contract.
{ description: 'Utländsk tjänst', quantity: 1, unitPrice: 500, lineTotal: 500, vatRate: 19 },
],
}
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-vat-2',
status: 'received',
extracted_data: mixedRates,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-vat-2',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-vat-2' },
'company-1', 'user-1', supabase,
)) as { staged: boolean }
expect(result.staged).toBe(true)
const params = inserts[0].params as {
vat_amount: number
items: Array<{ vat_rate: number; vat_amount: number }>
}
expect(params.items[0].vat_rate).toBe(0.12)
expect(params.items[0].vat_amount).toBe(120)
expect(params.items[1].vat_rate).toBe(0)
expect(params.items[1].vat_amount).toBe(0)
expect(params.vat_amount).toBe(120)
})
it('invoice_date_override rescues an inbox item with no extracted invoiceDate', async () => {
const extractedNoDate = {
...baseExtracted,
+19 -3
View File
@@ -37,6 +37,7 @@ import { prompts, findPrompt } from './prompts'
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
import type { SkillTier } from './skills'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
@@ -8111,16 +8112,31 @@ export const tools: McpTool[] = [
const lineItems = lineItemsExt.map((li, idx) => {
const lineNumber = idx + 1
const dimensions = resolvedDimBags[idx + 1]
const lineTotal = Number(li.line_total ?? li.lineTotal ?? li.amount) || 0
// The AI extraction contract (ExtractionSchema) carries vatRate as a
// percent integer (25, 12, 6) while supplier_invoice_items stores a
// decimal fraction (0.25): normalize at this boundary or vat_rate 25
// books 2500 % VAT downstream (issue #310). Foreign rates (19, 20)
// map to 0 per the extraction contract: the strict Swedish allowlist
// applies when converting to a supplier invoice.
const vatRate = normalizeVatRateToDecimal(li.vat_rate ?? li.vatRate)
// Real extractions carry no per-line VAT amount: derive it from the
// normalized rate so the staged header vat_amount (summed below) is
// honest instead of 0, which would gate the whole 2641 posting off.
const rawVatAmount = li.vat_amount ?? li.vatAmount
const vatAmount = rawVatAmount == null
? roundOre(lineTotal * vatRate)
: Number(rawVatAmount) || 0
return {
line_number: lineNumber,
description: (li.description as string) ?? `Position ${lineNumber}`,
quantity: Number(li.quantity) || 1,
unit: (li.unit as string) ?? 'st',
unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0,
line_total: Number(li.line_total ?? li.lineTotal ?? li.amount) || 0,
line_total: lineTotal,
account_number: lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000',
vat_rate: Number(li.vat_rate ?? li.vatRate) || 0,
vat_amount: Number(li.vat_amount ?? li.vatAmount) || 0,
vat_rate: vatRate,
vat_amount: vatAmount,
...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}),
}
})