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>
118 lines
3.8 KiB
TypeScript
118 lines
3.8 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
LEGAL_VAT_RATES,
|
|
isLegalVatRate,
|
|
normalizeVatRateToDecimal,
|
|
findIllegalVatRateRow,
|
|
findReverseChargeAccountWarningRows,
|
|
} from '@/lib/vat/supplier-invoice-line-checks'
|
|
|
|
describe('LEGAL_VAT_RATES', () => {
|
|
it('is exactly the legal Swedish set as decimal fractions', () => {
|
|
expect(LEGAL_VAT_RATES).toEqual([0.25, 0.12, 0.06, 0])
|
|
})
|
|
})
|
|
|
|
describe('isLegalVatRate', () => {
|
|
it.each([0.25, 0.12, 0.06, 0])('accepts %s', (rate) => {
|
|
expect(isLegalVatRate(rate)).toBe(true)
|
|
})
|
|
|
|
it.each([0.13, 0.17, 0.2, 0.1, 1, -0.25])('rejects %s', (rate) => {
|
|
expect(isLegalVatRate(rate)).toBe(false)
|
|
})
|
|
|
|
it('accepts a rate produced the way the form parses free text (typing 12 -> 12/100)', () => {
|
|
// VatRateCell stores parsed-percent / 100; the division must land exactly
|
|
// on the preset double for the strict includes() match to hold.
|
|
expect(isLegalVatRate(12 / 100)).toBe(true)
|
|
expect(isLegalVatRate(6 / 100)).toBe(true)
|
|
expect(isLegalVatRate(25 / 100)).toBe(true)
|
|
})
|
|
})
|
|
|
|
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 }]
|
|
expect(findIllegalVatRateRow(items)).toBe(-1)
|
|
})
|
|
|
|
it('returns -1 for an empty list', () => {
|
|
expect(findIllegalVatRateRow([])).toBe(-1)
|
|
})
|
|
|
|
it('returns the index of the first illegal line', () => {
|
|
const items = [{ vat_rate: 0.25 }, { vat_rate: 0.13 }, { vat_rate: 0.17 }]
|
|
expect(findIllegalVatRateRow(items)).toBe(1)
|
|
})
|
|
|
|
it('flags a 13 % rate typed into the free-text cell (13 / 100)', () => {
|
|
expect(findIllegalVatRateRow([{ vat_rate: 13 / 100 }])).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('findReverseChargeAccountWarningRows', () => {
|
|
it('flags class 1 and class 6 accounts', () => {
|
|
const items = [
|
|
{ account_number: '1930' },
|
|
{ account_number: '4010' },
|
|
{ account_number: '6540' },
|
|
]
|
|
expect(findReverseChargeAccountWarningRows(items)).toEqual([0, 2])
|
|
})
|
|
|
|
it('does not flag the expected 4xxx/5xxx cost accounts', () => {
|
|
const items = [{ account_number: '4515' }, { account_number: '5420' }]
|
|
expect(findReverseChargeAccountWarningRows(items)).toEqual([])
|
|
})
|
|
|
|
it('skips rows without an account (owned by the account-missing check)', () => {
|
|
const items = [{ account_number: '' }, { account_number: '1220' }]
|
|
expect(findReverseChargeAccountWarningRows(items)).toEqual([1])
|
|
})
|
|
|
|
it('returns an empty list for no items', () => {
|
|
expect(findReverseChargeAccountWarningRows([])).toEqual([])
|
|
})
|
|
|
|
it('treats account numbers as strings, not numbers', () => {
|
|
// '16' and '60' start with 1/6 as strings; a numeric range check would
|
|
// classify them differently.
|
|
expect(
|
|
findReverseChargeAccountWarningRows([
|
|
{ account_number: '1680' },
|
|
{ account_number: '6072' },
|
|
{ account_number: '7010' },
|
|
]),
|
|
).toEqual([0, 1])
|
|
})
|
|
})
|