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
@@ -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 () => {