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