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
@@ -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 }]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user