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:
co-authored by
Claude Fable 5
parent
03fd1b60b7
commit
5b8e3fa130
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user