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
@@ -191,3 +191,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled).
|
||||
[2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator.
|
||||
[2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult.
|
||||
[2026-07-17] Issue #310 vat_rate normalizer lives in lib/vat/supplier-invoice-line-checks.ts (shared by MCP staging and the pending-operation executor) instead of a local helper in mcp-server/server.ts: core cannot import extensions, and the legal-rate set (LEGAL_VAT_RATES) already lives there; duplicating the statutory set in two files invites drift.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -526,6 +526,90 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
|
||||
expect('dimensions' in params.items[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes percent vatRate from the real AI extraction shape and derives per-line vat_amount (issue #310)', async () => {
|
||||
// Real ExtractionSchema output: camelCase keys, vatRate as a percent
|
||||
// integer, and NO per-line VAT amount field. Staging must convert to the
|
||||
// decimal convention of supplier_invoice_items (0.25) and derive the
|
||||
// line's vat_amount, or the committed invoice books 2500 % VAT with a
|
||||
// dishonest header vat_amount of 0.
|
||||
const realExtractionShape = {
|
||||
...baseExtracted,
|
||||
totals: { subtotal: 1000, vatAmount: 250, total: 1250 },
|
||||
lineItems: [
|
||||
{ description: 'Konsulttimmar', quantity: 10, unitPrice: 100, lineTotal: 1000, vatRate: 25, accountSuggestion: null },
|
||||
],
|
||||
}
|
||||
const inserts: Array<Record<string, unknown>> = []
|
||||
const supabase = makeMock({
|
||||
inbox: {
|
||||
id: 'inbox-vat-1',
|
||||
status: 'received',
|
||||
extracted_data: realExtractionShape,
|
||||
matched_supplier_id: 'supplier-1',
|
||||
created_supplier_invoice_id: null,
|
||||
document_id: 'doc-vat-1',
|
||||
},
|
||||
inserts,
|
||||
})
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
|
||||
const result = (await tool.execute(
|
||||
{ inbox_item_id: 'inbox-vat-1' },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as { staged: boolean }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
const params = inserts[0].params as {
|
||||
vat_amount: number
|
||||
items: Array<{ vat_rate: number; vat_amount: number; line_total: number }>
|
||||
}
|
||||
expect(params.items[0].vat_rate).toBe(0.25)
|
||||
expect(params.items[0].vat_amount).toBe(250)
|
||||
expect(params.items[0].line_total).toBe(1000)
|
||||
// Header vat_amount is summed from the derived per-line amounts.
|
||||
expect(params.vat_amount).toBe(250)
|
||||
})
|
||||
|
||||
it('passes already-decimal vat_rate through unchanged and zeroes foreign percent rates', async () => {
|
||||
const mixedRates = {
|
||||
...baseExtracted,
|
||||
lineItems: [
|
||||
// Already decimal (staged by an agent following the storage convention).
|
||||
{ description: 'Svensk tjänst', quantity: 1, unit_price: 1000, line_total: 1000, vat_rate: 0.12, vat_amount: 120 },
|
||||
// Foreign rate (DE 19 %): outside the Swedish statutory set, maps to 0
|
||||
// per the extraction contract.
|
||||
{ description: 'Utländsk tjänst', quantity: 1, unitPrice: 500, lineTotal: 500, vatRate: 19 },
|
||||
],
|
||||
}
|
||||
const inserts: Array<Record<string, unknown>> = []
|
||||
const supabase = makeMock({
|
||||
inbox: {
|
||||
id: 'inbox-vat-2',
|
||||
status: 'received',
|
||||
extracted_data: mixedRates,
|
||||
matched_supplier_id: 'supplier-1',
|
||||
created_supplier_invoice_id: null,
|
||||
document_id: 'doc-vat-2',
|
||||
},
|
||||
inserts,
|
||||
})
|
||||
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
|
||||
const result = (await tool.execute(
|
||||
{ inbox_item_id: 'inbox-vat-2' },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as { staged: boolean }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
const params = inserts[0].params as {
|
||||
vat_amount: number
|
||||
items: Array<{ vat_rate: number; vat_amount: number }>
|
||||
}
|
||||
expect(params.items[0].vat_rate).toBe(0.12)
|
||||
expect(params.items[0].vat_amount).toBe(120)
|
||||
expect(params.items[1].vat_rate).toBe(0)
|
||||
expect(params.items[1].vat_amount).toBe(0)
|
||||
expect(params.vat_amount).toBe(120)
|
||||
})
|
||||
|
||||
it('invoice_date_override rescues an inbox item with no extracted invoiceDate', async () => {
|
||||
const extractedNoDate = {
|
||||
...baseExtracted,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { prompts, findPrompt } from './prompts'
|
||||
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
|
||||
import type { SkillTier } from './skills'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
@@ -8111,16 +8112,31 @@ export const tools: McpTool[] = [
|
||||
const lineItems = lineItemsExt.map((li, idx) => {
|
||||
const lineNumber = idx + 1
|
||||
const dimensions = resolvedDimBags[idx + 1]
|
||||
const lineTotal = Number(li.line_total ?? li.lineTotal ?? li.amount) || 0
|
||||
// The AI extraction contract (ExtractionSchema) carries vatRate as a
|
||||
// percent integer (25, 12, 6) while supplier_invoice_items stores a
|
||||
// decimal fraction (0.25): normalize at this boundary or vat_rate 25
|
||||
// books 2500 % VAT downstream (issue #310). Foreign rates (19, 20)
|
||||
// map to 0 per the extraction contract: the strict Swedish allowlist
|
||||
// applies when converting to a supplier invoice.
|
||||
const vatRate = normalizeVatRateToDecimal(li.vat_rate ?? li.vatRate)
|
||||
// Real extractions carry no per-line VAT amount: derive it from the
|
||||
// normalized rate so the staged header vat_amount (summed below) is
|
||||
// honest instead of 0, which would gate the whole 2641 posting off.
|
||||
const rawVatAmount = li.vat_amount ?? li.vatAmount
|
||||
const vatAmount = rawVatAmount == null
|
||||
? roundOre(lineTotal * vatRate)
|
||||
: Number(rawVatAmount) || 0
|
||||
return {
|
||||
line_number: lineNumber,
|
||||
description: (li.description as string) ?? `Position ${lineNumber}`,
|
||||
quantity: Number(li.quantity) || 1,
|
||||
unit: (li.unit as string) ?? 'st',
|
||||
unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0,
|
||||
line_total: Number(li.line_total ?? li.lineTotal ?? li.amount) || 0,
|
||||
line_total: lineTotal,
|
||||
account_number: lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000',
|
||||
vat_rate: Number(li.vat_rate ?? li.vatRate) || 0,
|
||||
vat_amount: Number(li.vat_amount ?? li.vatAmount) || 0,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -486,6 +486,82 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
|
||||
expect(items[0].vat_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('normalizes percent-shaped staged vat_rate (25) to the decimal convention on insert (issue #310)', async () => {
|
||||
let capturedItems: unknown = null
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const originalFrom = supabase.from
|
||||
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'supplier_invoice_items') {
|
||||
return {
|
||||
insert: (rows: unknown) => {
|
||||
capturedItems = rows
|
||||
return Promise.resolve({ data: null, error: null })
|
||||
},
|
||||
}
|
||||
}
|
||||
return (originalFrom as (t: string) => unknown)(table)
|
||||
})
|
||||
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: 42, error: null }) // arrival number
|
||||
enqueue({
|
||||
data: makeSupplierInvoice({ id: 'inv-pct', supplier_invoice_number: 'INV-100' }),
|
||||
error: null,
|
||||
})
|
||||
// supplier_invoice_items.insert handled by the override above
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null }) // cash: skips the JE
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items update
|
||||
enqueue({ data: null, error: null }) // dispatcher's commit update
|
||||
|
||||
const op = makePendingOp()
|
||||
op.params = {
|
||||
...(op.params as Record<string, unknown>),
|
||||
items: [
|
||||
{
|
||||
line_number: 1,
|
||||
description: 'Konsulttjänst',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
account_number: '6530',
|
||||
// Percent-shaped: staged before the issue #310 fix. Inserting 25
|
||||
// as-is would book 2500 % VAT via groupVatByRate downstream.
|
||||
vat_rate: 25,
|
||||
vat_amount: 250,
|
||||
},
|
||||
{
|
||||
line_number: 2,
|
||||
description: 'Utländsk tjänst',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 500,
|
||||
line_total: 500,
|
||||
account_number: '4531',
|
||||
// Foreign decimal rate: outside the statutory set, snaps to 0.
|
||||
vat_rate: 0.19,
|
||||
vat_amount: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
const items = capturedItems as Array<{ vat_rate: number; vat_amount: number }>
|
||||
expect(items[0].vat_rate).toBe(0.25)
|
||||
expect(items[0].vat_amount).toBe(250)
|
||||
expect(items[1].vat_rate).toBe(0)
|
||||
})
|
||||
|
||||
it('JE-failure rollback deletes items BEFORE the parent invoice (FK ordering)', async () => {
|
||||
// The parent has line items at this point: the rollback must reverse
|
||||
// insertion order or the FK on supplier_invoice_items blocks the parent
|
||||
|
||||
@@ -19,6 +19,7 @@ import { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/t
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
@@ -2276,7 +2277,10 @@ async function commitCreateSupplierInvoiceFromInbox(
|
||||
// 20-24 / 48 instead of double-counting input VAT into 2641. Tampered
|
||||
// params can't smuggle non-zero VAT into the items table.
|
||||
const itemInserts = rawItems.map((item, idx) => {
|
||||
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0)
|
||||
// Normalize percent-shaped rates (25 -> 0.25) and snap to the statutory
|
||||
// set: rows staged before the issue #310 fix (or tampered params) carry
|
||||
// percent integers, and inserting one books 2500 % VAT downstream.
|
||||
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' ? normalizeVatRateToDecimal(item.vat_rate) : 0)
|
||||
const vatAmt = reverseCharge ? 0 : (typeof item.vat_amount === 'number' && Number.isFinite(item.vat_amount) ? item.vat_amount : 0)
|
||||
return {
|
||||
supplier_invoice_id: invoice.id,
|
||||
|
||||
@@ -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