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
@@ -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