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
+1
View File
@@ -191,3 +191,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled).
[2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator.
[2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult.
[2026-07-17] Issue #310 vat_rate normalizer lives in lib/vat/supplier-invoice-line-checks.ts (shared by MCP staging and the pending-operation executor) instead of a local helper in mcp-server/server.ts: core cannot import extensions, and the legal-rate set (LEGAL_VAT_RATES) already lives there; duplicating the statutory set in two files invites drift.
@@ -159,6 +159,33 @@ describe('POST /api/supplier-invoices', () => {
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when vat_rate is percent-shaped (25 instead of 0.25, issue #310)', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-PERCENT',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
// Percent-integer shape: used to be accepted and silently booked
// 2500 % VAT (line_total * 25).
{ description: 'Material', quantity: 1, unit_price: 1000, account_number: '4010', vat_rate: 25 },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
type: string
errors: Array<{ field: string; message: string }>
}>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
expect(body.errors.some((e) => e.field === 'items.0.vat_rate')).toBe(true)
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('returns 404 when supplier not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
@@ -436,8 +436,12 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => {
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.attempted_rate).toBe(0.15)
expect(body.error.details.allowed_rates).toEqual([0, 0.06, 0.12, 0.25])
// Since issue #310 the shared Zod schema rejects non-statutory rates
// before the runtime ALLOWED_SV_VAT_RATES guard (kept as defense in
// depth), so the details carry Zod issues instead of attempted_rate.
const issues = body.error.details.issues as Array<{ field: string; message: string }>
expect(issues.some((i) => i.field === 'items.0.vat_rate')).toBe(true)
expect(issues.find((i) => i.field === 'items.0.vat_rate')!.message).toMatch(/decimal fraction/)
})
it('defaults vat_treatment to reverse_charge for eu_business suppliers', async () => {
@@ -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 } : {}),
}
})
+21
View File
@@ -831,6 +831,27 @@ describe('CreateSupplierInvoiceItemSchema', () => {
}
})
it('rejects percent-shaped or non-statutory vat_rate (decimal convention, issue #310)', () => {
// 25/12/6 are the percent-integer shape (books 2500 % VAT if accepted),
// 0.19 is a foreign decimal rate, 100 is the old max() boundary.
for (const rate of [25, 12, 6, 0.19, 100]) {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ vat_rate: rate })
)
expect(result.success).toBe(false)
}
})
it('rejects percent-shaped vat_rate with a unit hint in the message', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ vat_rate: 25 })
)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toMatch(/decimal fraction/)
}
})
it('accepts vat_amount up to line_total * vat_rate', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 1250 })
+13 -1
View File
@@ -41,6 +41,18 @@ const revenueAccount = z
/** Swedish VAT rate as an integer percent. */
const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
/**
* Swedish VAT rate as a decimal fraction: the supplier-invoice convention.
* supplier_invoice_items stores 0.25 for 25 % (DB default 0.25) while
* invoice_items stores integer percent (vatRatePercent above); issue #310.
* Only statutory rates pass; percent-shaped input (25) is rejected with a
* unit hint instead of silently booking 2500 % VAT.
*/
const vatRateDecimal = z.union(
[z.literal(0), z.literal(0.06), z.literal(0.12), z.literal(0.25)],
{ error: 'vat_rate is a decimal fraction: 0, 0.06, 0.12 or 0.25 (not percent)' },
)
/** Time string (HH:MM or HH:MM:SS) */
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
@@ -754,7 +766,7 @@ export const CreateSupplierInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
amount: z.number().optional(),
account_number: accountNumber,
vat_rate: z.number().min(0).max(100).optional(),
vat_rate: vatRateDecimal.optional(),
// Manual VAT override. When provided, the engine books this exact amount to
// 2641/2645 instead of recomputing line_total × vat_rate. Use for partial-
// deductible cases (bilförmån 50%, representation 300 kr-tak), foreign-
@@ -486,6 +486,82 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
expect(items[0].vat_amount).toBe(0)
})
it('normalizes percent-shaped staged vat_rate (25) to the decimal convention on insert (issue #310)', async () => {
let capturedItems: unknown = null
const { supabase, enqueue } = createQueuedMockSupabase()
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoice_items') {
return {
insert: (rows: unknown) => {
capturedItems = rows
return Promise.resolve({ data: null, error: null })
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null }) // arrival number
enqueue({
data: makeSupplierInvoice({ id: 'inv-pct', supplier_invoice_number: 'INV-100' }),
error: null,
})
// supplier_invoice_items.insert handled by the override above
enqueue({ data: { accounting_method: 'cash' }, error: null }) // cash: skips the JE
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
// Percent-shaped: staged before the issue #310 fix. Inserting 25
// as-is would book 2500 % VAT via groupVatByRate downstream.
vat_rate: 25,
vat_amount: 250,
},
{
line_number: 2,
description: 'Utländsk tjänst',
quantity: 1,
unit: 'st',
unit_price: 500,
line_total: 500,
account_number: '4531',
// Foreign decimal rate: outside the statutory set, snaps to 0.
vat_rate: 0.19,
vat_amount: 0,
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const items = capturedItems as Array<{ vat_rate: number; vat_amount: number }>
expect(items[0].vat_rate).toBe(0.25)
expect(items[0].vat_amount).toBe(250)
expect(items[1].vat_rate).toBe(0)
})
it('JE-failure rollback deletes items BEFORE the parent invoice (FK ordering)', async () => {
// The parent has line items at this point: the rollback must reverse
// insertion order or the FK on supplier_invoice_items blocks the parent
+5 -1
View File
@@ -19,6 +19,7 @@ import { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/t
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
@@ -2276,7 +2277,10 @@ async function commitCreateSupplierInvoiceFromInbox(
// 20-24 / 48 instead of double-counting input VAT into 2641. Tampered
// params can't smuggle non-zero VAT into the items table.
const itemInserts = rawItems.map((item, idx) => {
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0)
// Normalize percent-shaped rates (25 -> 0.25) and snap to the statutory
// set: rows staged before the issue #310 fix (or tampered params) carry
// percent integers, and inserting one books 2500 % VAT downstream.
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' ? normalizeVatRateToDecimal(item.vat_rate) : 0)
const vatAmt = reverseCharge ? 0 : (typeof item.vat_amount === 'number' && Number.isFinite(item.vat_amount) ? item.vat_amount : 0)
return {
supplier_invoice_id: invoice.id,
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
LEGAL_VAT_RATES,
isLegalVatRate,
normalizeVatRateToDecimal,
findIllegalVatRateRow,
findReverseChargeAccountWarningRows,
} from '@/lib/vat/supplier-invoice-line-checks'
@@ -30,6 +31,34 @@ describe('isLegalVatRate', () => {
})
})
describe('normalizeVatRateToDecimal', () => {
it.each([
[25, 0.25],
[12, 0.12],
[6, 0.06],
])('converts percent-integer %s to decimal %s', (percent, decimal) => {
expect(normalizeVatRateToDecimal(percent)).toBe(decimal)
})
it.each([0, 0.06, 0.12, 0.25])('passes already-decimal %s through unchanged', (rate) => {
expect(normalizeVatRateToDecimal(rate)).toBe(rate)
})
it.each([19, 20, 0.19, 0.2, 1, 100, -0.25, -25])(
'maps non-statutory rate %s to 0 (strict Swedish allowlist)',
(rate) => {
expect(normalizeVatRateToDecimal(rate)).toBe(0)
},
)
it('maps missing or non-finite input to 0', () => {
expect(normalizeVatRateToDecimal(null)).toBe(0)
expect(normalizeVatRateToDecimal(undefined)).toBe(0)
expect(normalizeVatRateToDecimal(Number.NaN)).toBe(0)
expect(normalizeVatRateToDecimal(Number.POSITIVE_INFINITY)).toBe(0)
})
})
describe('findIllegalVatRateRow', () => {
it('returns -1 when every line is legal', () => {
const items = [{ vat_rate: 0.25 }, { vat_rate: 0.12 }, { vat_rate: 0 }]
+21
View File
@@ -1,6 +1,8 @@
// Pure submit-time checks for supplier invoice line items (issue #863).
// Kept free of React so the rules can be unit-tested and reused.
import { roundOre } from '@/lib/money'
// Only 25/12/6/0 % are legal Swedish VAT rates (ML 2023:200). The supplier
// invoice form stores rates as decimal fractions (0.25 = 25 %); this list is
// also the preset dropdown in the form's VAT rate cell.
@@ -15,6 +17,25 @@ export function isLegalVatRate(rate: number): boolean {
return LEGAL_VAT_RATES.includes(rate)
}
/**
* Normalize a VAT rate that may arrive percent-shaped (25, 12, 6: the AI
* extraction contract and stale staged pending_operations params) to the
* decimal-fraction convention used by supplier_invoice_items (0.25, 0.12,
* 0.06); issue #310. Values above 1 are treated as percent and divided by
* 100; the result is snapped to the legal Swedish set and anything else
* (foreign 19/20, non-finite or missing input) maps to 0, mirroring the
* extraction contract: the strict Swedish allowlist applies when converting
* to a supplier invoice.
*/
export function normalizeVatRateToDecimal(rate: unknown): number {
const n = Number(rate)
if (!Number.isFinite(n)) return 0
// roundOre is 2-decimal rounding: exactly the snap a decimal fraction of an
// integer percent needs (25 / 100 must land on the legal-set double).
const decimal = roundOre(n > 1 ? n / 100 : n)
return isLegalVatRate(decimal) ? decimal : 0
}
/**
* Index of the first line whose VAT rate falls outside the legal Swedish set,
* or -1 when every line is legal. Reverse charge invoices should skip this