From 8443062b1f8749c9b7a10ae70d7c1c133d1b6fd3 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:49:46 +0200 Subject: [PATCH] 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 --- .../[id]/credit/__tests__/route.test.ts | 161 ++++++++++++++++++ .../supplier-invoices/[id]/credit/route.ts | 3 +- .../supplier-invoices/[id]/credit/route.ts | 7 +- .../supplier-invoices/__tests__/route.test.ts | 13 +- .../__tests__/entity-mapper-fx.test.ts | 19 +++ .../arcim-migration/lib/entity-mapper.ts | 6 +- .../__tests__/executors.test.ts | 83 +++++++++ lib/pending-operations/commit.ts | 9 +- .../supplier-invoice-line-checks.test.ts | 32 ++++ lib/vat/supplier-invoice-line-checks.ts | 19 ++- ...rce_supplier_invoice_vat_rate_fraction.sql | 11 ++ ...plier-invoice-vat-rate-fraction.pg.test.ts | 66 +++++++ 12 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 app/api/supplier-invoices/[id]/credit/__tests__/route.test.ts create mode 100644 supabase/migrations/20260803223000_enforce_supplier_invoice_vat_rate_fraction.sql create mode 100644 tests/pg/supplier-invoice-vat-rate-fraction.pg.test.ts diff --git a/app/api/supplier-invoices/[id]/credit/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/credit/__tests__/route.test.ts new file mode 100644 index 00000000..52b8c923 --- /dev/null +++ b/app/api/supplier-invoices/[id]/credit/__tests__/route.test.ts @@ -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', + ) + }) +}) diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 1a3c3c0f..f4110a7f 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -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. diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts index 018ff972..7f8eef18 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route.ts @@ -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, ) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts index 931868f3..228b1ee1 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -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) diff --git a/extensions/general/arcim-migration/__tests__/entity-mapper-fx.test.ts b/extensions/general/arcim-migration/__tests__/entity-mapper-fx.test.ts index 8255c311..8f5f9819 100644 --- a/extensions/general/arcim-migration/__tests__/entity-mapper-fx.test.ts +++ b/extensions/general/arcim-migration/__tests__/entity-mapper-fx.test.ts @@ -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) => ({ diff --git a/extensions/general/arcim-migration/lib/entity-mapper.ts b/extensions/general/arcim-migration/lib/entity-mapper.ts index 8ee232e1..815de490 100644 --- a/extensions/general/arcim-migration/lib/entity-mapper.ts +++ b/extensions/general/arcim-migration/lib/entity-mapper.ts @@ -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), } } diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 17eae60b..69feac6b 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -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( + '@/lib/bookkeeping/supplier-invoice-entries' + ) + return { + ...actual, + createSupplierCreditNoteEntry: vi.fn(), + } +}) + vi.mock('@/lib/transactions/categorize-core', async () => { const actual = await vi.importActual( @@ -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', () => { diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index b0fe9895..0f32487d 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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 ) diff --git a/lib/vat/__tests__/supplier-invoice-line-checks.test.ts b/lib/vat/__tests__/supplier-invoice-line-checks.test.ts index 1d33f89c..ea84d9ae 100644 --- a/lib/vat/__tests__/supplier-invoice-line-checks.test.ts +++ b/lib/vat/__tests__/supplier-invoice-line-checks.test.ts @@ -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 }] diff --git a/lib/vat/supplier-invoice-line-checks.ts b/lib/vat/supplier-invoice-line-checks.ts index 8c482fa2..29119416 100644 --- a/lib/vat/supplier-invoice-line-checks.ts +++ b/lib/vat/supplier-invoice-line-checks.ts @@ -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 } diff --git a/supabase/migrations/20260803223000_enforce_supplier_invoice_vat_rate_fraction.sql b/supabase/migrations/20260803223000_enforce_supplier_invoice_vat_rate_fraction.sql new file mode 100644 index 00000000..bfc18eed --- /dev/null +++ b/supabase/migrations/20260803223000_enforce_supplier_invoice_vat_rate_fraction.sql @@ -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.'; diff --git a/tests/pg/supplier-invoice-vat-rate-fraction.pg.test.ts b/tests/pg/supplier-invoice-vat-rate-fraction.pg.test.ts new file mode 100644 index 00000000..496fa67a --- /dev/null +++ b/tests/pg/supplier-invoice-vat-rate-fraction.pg.test.ts @@ -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 { + 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' }) + }) +})