fix(vat): enforce fraction unit for supplier invoice vat_rate writes (#1385)

Closes the remaining #310 write paths: credit-note item copies (web, v1,
pending-operations) and arcim-migration supplier imports now normalize
vat_rate to the decimal-fraction unit before storage, and a NOT VALID
CHECK constraint guards every new supplier_invoice_items row. Customer
invoice items deliberately stay percent; legacy supplier rows are left
untouched so posted-entry reversals reuse the exact original values.

Fixes #310

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 18:49:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5b9605d8e9
commit 8443062b1f
12 changed files with 416 additions and 13 deletions
@@ -11,6 +11,7 @@ import {
makeCustomer,
makeInvoice,
makeFiscalPeriod,
makeSupplierInvoice,
} from '@/tests/helpers'
import type { PendingOperation } from '@/types'
@@ -66,6 +67,17 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
}
})
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
const actual =
await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
'@/lib/bookkeeping/supplier-invoice-entries'
)
return {
...actual,
createSupplierCreditNoteEntry: vi.fn(),
}
})
vi.mock('@/lib/transactions/categorize-core', async () => {
const actual =
await vi.importActual<typeof import('@/lib/transactions/categorize-core')>(
@@ -107,6 +119,7 @@ import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
@@ -849,6 +862,76 @@ describe('commitPendingOperation: credit_invoice', () => {
})
})
// ─── credit_supplier_invoice ────────────────────────────────────────
describe('commitPendingOperation: credit_supplier_invoice', () => {
it('normalizes copied storage and reverses with the untouched original items', async () => {
const originalItems = [
{
sort_order: 0,
description: 'Office supplies',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '5410',
vat_code: null,
vat_rate: 25,
vat_amount: 250,
dimensions: {},
},
]
const original = {
...makeSupplierInvoice({ id: 'supplier-invoice-1', status: 'registered' }),
supplier: { name: 'Office Depot AB', supplier_type: 'swedish_business' },
items: originalItems,
}
const creditNote = makeSupplierInvoice({
id: 'supplier-credit-1',
is_credit_note: true,
credited_invoice_id: original.id,
})
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
enqueueMany([
{ data: { id: 'op-1' }, error: null },
{ data: original, error: null },
{ data: 2, error: null },
{ data: creditNote, error: null },
{ data: null, error: null },
{ data: { accounting_method: 'accrual' }, error: null },
{ data: null, error: null },
{ data: null, error: null },
{ data: null, error: null },
])
vi.mocked(createSupplierCreditNoteEntry).mockResolvedValueOnce({ id: 'je-1' } as never)
const op = makePendingOp({
operation_type: 'credit_supplier_invoice',
params: { supplier_invoice_id: original.id },
})
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
op,
)
expect(result.status).toBe('committed')
const insertArgs = findCall('supplier_invoice_items', 'insert')
const insertedItems = insertArgs?.[0] as Array<{ vat_rate: number }>
expect(insertedItems[0]?.vat_rate).toBe(0.25)
expect(createSupplierCreditNoteEntry).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
creditNote,
originalItems,
'swedish_business',
'Office Depot AB',
)
})
})
// ─── attach_document_to_transaction ─────────────────────────────────
describe('commitPendingOperation: attach_document_to_transaction', () => {
+6 -3
View File
@@ -24,7 +24,10 @@ import {
} from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import {
normalizeVatRateToDecimal,
normalizeVatRateToFraction,
} from '@/lib/vat/supplier-invoice-line-checks'
import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
@@ -3729,7 +3732,7 @@ async function commitCreditSupplierInvoice(
line_total: item.line_total,
account_number: item.account_number,
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_rate: normalizeVatRateToFraction(item.vat_rate),
vat_amount: item.vat_amount,
dimensions: item.dimensions ?? {},
}))
@@ -3747,7 +3750,7 @@ async function commitCreditSupplierInvoice(
companyId,
userId,
creditNote,
creditItems as never,
original.items as never,
original.supplier?.supplier_type || 'swedish_business',
original.supplier?.name
)
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
LEGAL_VAT_RATES,
isLegalVatRate,
normalizeVatRateToFraction,
normalizeVatRateToDecimal,
findIllegalVatRateRow,
findReverseChargeAccountWarningRows,
@@ -60,6 +61,37 @@ describe('normalizeVatRateToDecimal', () => {
})
})
describe('normalizeVatRateToFraction', () => {
it.each([
[25, 0.25],
[19, 0.19],
[12, 0.12],
[6, 0.06],
[100, 1],
])('converts percent-shaped %s to fraction %s', (percent, fraction) => {
expect(normalizeVatRateToFraction(percent)).toBe(fraction)
})
it.each([0, 0.06, 0.12, 0.19, 0.24995, 0.25, 1])(
'preserves already-fractional %s',
(rate) => {
expect(normalizeVatRateToFraction(rate)).toBe(rate)
},
)
it.each([-25, -0.25, 101, Number.NaN, Number.POSITIVE_INFINITY])(
'maps out-of-range value %s to 0',
(rate) => {
expect(normalizeVatRateToFraction(rate)).toBe(0)
},
)
it('maps missing input to 0', () => {
expect(normalizeVatRateToFraction(null)).toBe(0)
expect(normalizeVatRateToFraction(undefined)).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 }]
+16 -3
View File
@@ -17,6 +17,21 @@ export function isLegalVatRate(rate: number): boolean {
return LEGAL_VAT_RATES.includes(rate)
}
/**
* Convert a supplier-invoice VAT rate to the database's decimal-fraction
* unit without changing the underlying rate. Values above 1 are interpreted
* as percentages, while values already between 0 and 1 pass through. This is
* intentionally a unit normalizer, not a Swedish-rate validator: imported
* foreign VAT such as 19 % must remain 0.19 instead of being rewritten.
*/
export function normalizeVatRateToFraction(rate: unknown): number {
const n = Number(rate)
if (!Number.isFinite(n) || n < 0) return 0
const fraction = n > 1 ? n / 100 : n
return fraction <= 1 ? fraction : 0
}
/**
* Normalize a VAT rate that may arrive percent-shaped (25, 12, 6: the AI
* extraction contract and stale staged pending_operations params) to the
@@ -28,11 +43,9 @@ export function isLegalVatRate(rate: number): boolean {
* 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)
const decimal = roundOre(normalizeVatRateToFraction(rate))
return isLegalVatRate(decimal) ? decimal : 0
}