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