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:
co-authored by
Claude Fable 5
parent
5b9605d8e9
commit
8443062b1f
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user