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
@@ -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)