Files
accounted/lib/vat/__tests__/supplier-invoice-line-checks.test.ts
T
Jakob WennbergandClaude Fable 5 5b8e3fa130 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>
2026-07-17 13:28:36 +02:00

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])
})
})