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:
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeSupplierInvoice,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const {
|
||||
supabase: mockSupabase,
|
||||
enqueue,
|
||||
enqueueMany,
|
||||
findCall,
|
||||
reset,
|
||||
} = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const createCreditEntryMock = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierCreditNoteEntry: (...args: unknown[]) => createCreditEntryMock(...args),
|
||||
}))
|
||||
|
||||
const cancelSchedulesMock = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
|
||||
cancelSchedulesForSource: (...args: unknown[]) => cancelSchedulesMock(...args),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/supplier-invoices/[id]/credit', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const legacyItem = {
|
||||
id: 'item-1',
|
||||
supplier_invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
description: 'Kontorsmaterial',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
account_number: '5410',
|
||||
vat_code: null,
|
||||
vat_rate: 25,
|
||||
vat_amount: 250,
|
||||
reverse_charge_rate: null,
|
||||
dimensions: {},
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
const original = {
|
||||
...makeSupplierInvoice({ id: 'invoice-1', status: 'registered' }),
|
||||
supplier: { name: 'Leverantör AB', supplier_type: 'swedish_business' },
|
||||
items: [legacyItem],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: mockUser,
|
||||
supabase: mockSupabase,
|
||||
error: null,
|
||||
})
|
||||
cancelSchedulesMock.mockResolvedValue({ failedReversals: 0 })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices/invoice-1/credit', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the supplier invoice does not exist', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices/missing/credit', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'missing' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when the supplier invoice is already credited', async () => {
|
||||
enqueue({ data: { ...original, status: 'credited' }, error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices/invoice-1/credit', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
})
|
||||
|
||||
it('normalizes copied item storage but keeps original items for reversal', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
id: 'credit-1',
|
||||
is_credit_note: true,
|
||||
credited_invoice_id: 'invoice-1',
|
||||
})
|
||||
enqueueMany([
|
||||
{ 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 },
|
||||
])
|
||||
createCreditEntryMock.mockResolvedValue({ id: 'journal-1' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/supplier-invoices/invoice-1/credit', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
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(createCreditEntryMock).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
creditNote,
|
||||
original.items,
|
||||
'swedish_business',
|
||||
'Leverantör AB',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { normalizeVatRateToFraction } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -85,7 +86,7 @@ export const POST = withRouteContext(
|
||||
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,
|
||||
// Preserve the self-assessed RC rate so the credit-note verifikat
|
||||
// reverses fiktiv moms at the same rate the original was booked at.
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoic
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { normalizeVatRateToFraction } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { AccountingMethod, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
@@ -219,7 +220,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
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,
|
||||
reverse_charge_rate: item.reverse_charge_rate,
|
||||
}))
|
||||
@@ -314,7 +315,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
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,
|
||||
// Preserve the self-assessed RC rate so the credit note reverses fiktiv
|
||||
// moms at the same rate the original was booked at.
|
||||
@@ -356,7 +357,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
creditNote as unknown as SupplierInvoice,
|
||||
creditItems as unknown as SupplierInvoiceItem[],
|
||||
typed.items as unknown as SupplierInvoiceItem[],
|
||||
supplierRow?.supplier_type ?? 'swedish_business',
|
||||
supplierRow?.name,
|
||||
)
|
||||
|
||||
@@ -1486,7 +1486,7 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
line_total: 1000,
|
||||
account_number: '5410',
|
||||
vat_code: null,
|
||||
vat_rate: 0.25,
|
||||
vat_rate: 25,
|
||||
vat_amount: 250,
|
||||
},
|
||||
],
|
||||
@@ -1502,6 +1502,7 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
credited_invoice_id: SI_ID,
|
||||
}
|
||||
let siReadCount = 0
|
||||
let insertedItems: Array<{ vat_rate: number }> = []
|
||||
mockServiceClient.mockReturnValue({
|
||||
from: (table: string) => {
|
||||
return new Proxy({}, {
|
||||
@@ -1528,7 +1529,12 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => new Proxy({}, this!)
|
||||
return (...args: unknown[]) => {
|
||||
if (table === 'supplier_invoice_items' && prop === 'insert') {
|
||||
insertedItems = args[0] as Array<{ vat_rate: number }>
|
||||
}
|
||||
return new Proxy({}, this!)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -1544,6 +1550,9 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockedCredit).toHaveBeenCalledTimes(1)
|
||||
expect(insertedItems[0]?.vat_rate).toBe(0.25)
|
||||
expect(mockedCredit.mock.calls[0]?.[4]).toBe(registeredSI.items)
|
||||
expect((mockedCredit.mock.calls[0]?.[4] as typeof registeredSI.items)[0]?.vat_rate).toBe(25)
|
||||
const body = await res.json()
|
||||
expect(body.data.credit_note_id).toBe(creditNoteRow.id)
|
||||
expect(body.data.original_id).toBe(SI_ID)
|
||||
|
||||
@@ -91,6 +91,25 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('invoice item VAT-rate units', () => {
|
||||
it('keeps customer items in percent and supplier items in decimal fractions', () => {
|
||||
const sales = mapSalesInvoice(salesDto(), 'user-1', 'company-1', 'customer-1')
|
||||
const supplier = mapSupplierInvoice(supplierDto(), 'user-1', 'company-1', 'supplier-1')
|
||||
|
||||
expect(sales.items[0]?.vat_rate).toBe(25)
|
||||
expect(supplier.items[0]?.vat_rate).toBe(0.25)
|
||||
})
|
||||
|
||||
it('preserves a foreign supplier rate while converting its unit', () => {
|
||||
const dto = supplierDto()
|
||||
dto.lines[0]!.taxPercent = 19
|
||||
|
||||
const supplier = mapSupplierInvoice(dto, 'user-1', 'company-1', 'supplier-1')
|
||||
|
||||
expect(supplier.items[0]?.vat_rate).toBe(0.19)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFxRateIndex', () => {
|
||||
it('fetches the rate for each document DATE, not today, and caches per pair', async () => {
|
||||
;(fetchExchangeRate as Mock).mockImplementation(async (currency: string, date: Date) => ({
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { encryptCustomerPersonalNumber } from '@/lib/customers/protect-personal-number'
|
||||
import { normalizeVatRateToFraction } from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import type { Currency, CustomerType, ExchangeRate, SupplierType, VatTreatment } from '@/types'
|
||||
import type {
|
||||
CustomerDto,
|
||||
@@ -664,7 +665,10 @@ function mapSupplierInvoiceLine(line: SupplierInvoiceLineDto, index: number): Re
|
||||
unit_price: round2(line.unitPrice?.value ?? line.lineExtensionAmount.value),
|
||||
line_total: round2(line.lineExtensionAmount.value),
|
||||
account_number: line.accountNumber || '4000', // Default to purchases
|
||||
vat_rate: inferVatRate(line.taxPercent),
|
||||
// supplier_invoice_items stores decimal fractions (0.25 = 25 %), unlike
|
||||
// customer invoice_items which store percent; foreign rates (19 % DE)
|
||||
// must survive as 0.19 rather than be coerced to a Swedish rate.
|
||||
vat_rate: normalizeVatRateToFraction(line.taxPercent ?? 25),
|
||||
vat_amount: round2(line.taxAmount?.value ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Supplier invoice item VAT rates use decimal fractions (0.25 = 25 %).
|
||||
-- Keep the guard NOT VALID so existing legacy percent-shaped rows do not
|
||||
-- block deployment; PostgreSQL still enforces it for every new or updated row.
|
||||
ALTER TABLE public.supplier_invoice_items
|
||||
ADD CONSTRAINT supplier_invoice_items_vat_rate_fraction
|
||||
CHECK (vat_rate BETWEEN 0 AND 1)
|
||||
NOT VALID;
|
||||
|
||||
COMMENT ON CONSTRAINT supplier_invoice_items_vat_rate_fraction
|
||||
ON public.supplier_invoice_items
|
||||
IS 'Supplier invoice VAT rate stored as a decimal fraction between 0 and 1.';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from './fixtures'
|
||||
import { getPool } from './setup'
|
||||
|
||||
async function seedSupplierInvoice(): Promise<{
|
||||
invoiceId: string
|
||||
userId: string
|
||||
companyId: string
|
||||
}> {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = randomUUID()
|
||||
const invoiceId = randomUUID()
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers (id, user_id, company_id, name)
|
||||
VALUES ($1, $2, $3, 'Testleverantör AB')`,
|
||||
[supplierId, userId, companyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number,
|
||||
supplier_invoice_number, invoice_date, due_date,
|
||||
subtotal, vat_amount, total)
|
||||
VALUES ($1, $2, $3, $4, floor(random() * 1000000)::int,
|
||||
$5, '2026-08-01', '2026-08-31', 1000, 250, 1250)`,
|
||||
[invoiceId, userId, companyId, supplierId, `VAT-${invoiceId.slice(0, 8)}`],
|
||||
)
|
||||
|
||||
return { invoiceId, userId, companyId }
|
||||
}
|
||||
|
||||
async function insertItem(invoiceId: string, vatRate: number): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoice_items
|
||||
(supplier_invoice_id, description, account_number, line_total,
|
||||
vat_rate, vat_amount)
|
||||
VALUES ($1, 'Kontorsmaterial', '5410', 1000, $2, 250)`,
|
||||
[invoiceId, vatRate],
|
||||
)
|
||||
}
|
||||
|
||||
describe('supplier_invoice_items VAT-rate fraction constraint', () => {
|
||||
it('exists as NOT VALID so legacy rows do not block deployment', async () => {
|
||||
const result = await getPool().query<{ convalidated: boolean }>(
|
||||
`SELECT convalidated
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'supplier_invoice_items_vat_rate_fraction'
|
||||
AND conrelid = 'public.supplier_invoice_items'::regclass`,
|
||||
)
|
||||
|
||||
expect(result.rows).toEqual([{ convalidated: false }])
|
||||
})
|
||||
|
||||
it('accepts a decimal-fraction VAT rate', async () => {
|
||||
const { invoiceId } = await seedSupplierInvoice()
|
||||
|
||||
await expect(insertItem(invoiceId, 0.25)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a percent-shaped VAT rate on a new row', async () => {
|
||||
const { invoiceId } = await seedSupplierInvoice()
|
||||
|
||||
await expect(insertItem(invoiceId, 25)).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user