Files
accounted/app/api/supplier-invoices/__tests__/route.test.ts
T
Jakob WennbergandClaude Fable 5 5b8e3fa130 fix(vat): enforce decimal vat_rate on supplier invoice items and normalize MCP percent extraction (#1049)
Supplier invoice items store vat_rate as a decimal fraction (0.25) while
customer invoices use integer percent (25). The shared Zod schema accepted
0-100, so a percent-shaped vat_rate silently booked 2500 % VAT via
line_total * vat_rate, and the MCP inbox-conversion path staged the AI
extraction's percent-integer vatRate straight into the decimal column with
per-line vat_amount 0. Part of #310.

- CreateSupplierInvoiceItemSchema.vat_rate is now a literal union of the
  statutory decimal set (0, 0.06, 0.12, 0.25) with a unit-hint error,
  covering the cookie route, the invoice-inbox convert route, and /api/v1
  (whose runtime ALLOWED_SV_VAT_RATES guard stays as defense in depth).
- New shared normalizeVatRateToDecimal() in lib/vat: percent-shaped values
  (25, 12, 6) divide by 100, results snap to the legal Swedish set, and
  anything else (foreign 19/20, non-finite) maps to 0.
- gnubok_create_supplier_invoice_from_inbox normalizes vatRate at the
  extraction boundary and derives per-line vat_amount when the extraction
  carries none, so the staged header vat_amount is honest.
- The pending-operation executor normalizes staged vat_rate on insert, so
  rows staged before this fix cannot book percent-scaled VAT.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:28:36 +02:00

907 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeSupplierInvoice,
makeSupplier,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
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 mockFindFiscalPeriod = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
}))
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
mockCreateSupplierInvoiceRegistrationEntry(...args),
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) =>
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
}))
const mockLinkToJournalEntry = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
import { eventBus } from '@/lib/events'
import { GET, POST } from '../route'
describe('GET /api/supplier-invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/supplier-invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns supplier invoices list', async () => {
const invoices = [makeSupplierInvoice(), makeSupplierInvoice()]
enqueue({ data: invoices, error: null })
const request = createMockRequest('/api/supplier-invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(invoices)
})
it('applies status filter', async () => {
enqueue({ data: [], error: null })
const request = createMockRequest('/api/supplier-invoices', {
searchParams: { status: 'registered' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices')
})
it('handles to_pay virtual status', async () => {
enqueue({ data: [], error: null })
const request = createMockRequest('/api/supplier-invoices', {
searchParams: { status: 'to_pay' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('applies supplier_id filter', async () => {
const invoices = [makeSupplierInvoice({ supplier_id: 'supplier-1' })]
enqueue({ data: invoices, error: null })
const request = createMockRequest('/api/supplier-invoices', {
searchParams: { status: 'all', supplier_id: 'supplier-1' },
})
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(invoices)
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'DB error' } })
const request = createMockRequest('/api/supplier-invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR')
})
})
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
const DOCUMENT_UUID = '550e8400-e29b-41d4-a716-446655440002'
describe('POST /api/supplier-invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: { supplier_id: VALID_UUID, items: [] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when vat_rate is percent-shaped (25 instead of 0.25, issue #310)', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-PERCENT',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
// Percent-integer shape: used to be accepted and silently booked
// 2500 % VAT (line_total * 25).
{ description: 'Material', quantity: 1, unit_price: 1000, account_number: '4010', vat_rate: 25 },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
type: string
errors: Array<{ field: string; message: string }>
}>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
expect(body.errors.some((e) => e.field === 'items.0.vat_rate')).toBe(true)
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('returns 404 when supplier not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID_2,
supplier_invoice_number: 'LF-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('SUPPLIER_NOT_FOUND')
})
it('creates supplier invoice with items and arrival number', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
// Fetch supplier
enqueue({ data: supplier, error: null })
// RPC get_next_arrival_number
enqueue({ data: 5 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice with registration_journal_entry_id
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{
description: 'Material',
quantity: 10,
unit_price: 800,
account_number: '4010',
vat_rate: 0.25,
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { registration_journal_entry_id: string }
}>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
expect(body.data.registration_journal_entry_id).toBe('je-1')
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
})
it('registers WITHOUT booking when defer_invoice_booking is on (#967)', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-deferred' })
// Fetch supplier
enqueue({ data: supplier, error: null })
// RPC get_next_arrival_number
enqueue({ data: 5 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings: accrual + deferred booking
enqueue({ data: { accounting_method: 'accrual', defer_invoice_booking: true }, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-002',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{
description: 'Material',
quantity: 10,
unit_price: 800,
account_number: '4010',
vat_rate: 0.25,
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { registration_journal_entry_id: string | null }
}>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
// No registration verifikat: booking is a separate explicit step.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(body.data.registration_journal_entry_id ?? null).toBeNull()
})
it('stores an uploaded document and links it to the registration entry', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID })
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: supplier, error: null })
enqueue({ data: 6 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-document' })
enqueue({ data: null, error: null })
mockLinkToJournalEntry.mockResolvedValue({ id: DOCUMENT_UUID })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
document_id: DOCUMENT_UUID,
supplier_invoice_number: 'LF-DOCUMENT',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { document_id: string; registration_journal_entry_id: string }
}>(response)
expect(status).toBe(200)
expect(body.data.document_id).toBe(DOCUMENT_UUID)
expect(body.data.registration_journal_entry_id).toBe('je-document')
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
DOCUMENT_UUID,
'je-document',
)
})
it('rejects a document that is missing or outside the active company', async () => {
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
document_id: DOCUMENT_UUID,
supplier_invoice_number: 'LF-INVALID-DOCUMENT',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
})
it('emits supplier_invoice.registered event', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 5 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{ description: 'Material', quantity: 10, unit_price: 800, account_number: '4010', vat_rate: 0.25 },
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.registered',
payload: expect.objectContaining({ userId: 'user-1' }),
})
)
})
it('skips registration entry for cash method', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 6 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-002',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6200' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { registration_journal_entry_id: null }
}>(response)
expect(status).toBe(200)
expect(body.data.registration_journal_entry_id).toBeNull()
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('rolls back on items insertion failure', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 7 })
enqueue({ data: createdInvoice, error: null })
// Items fail
enqueue({ data: null, error: { message: 'Items insert failed' } })
// Rollback delete
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-003',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
})
it('rolls back and returns SI_CREATE_NO_FISCAL_PERIOD when invoice_date is outside every fiscal period', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1', invoice_date: '2099-06-01' })
// Fetch supplier
enqueue({ data: supplier, error: null })
// RPC get_next_arrival_number
enqueue({ data: 9 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings → accrual, so a registration JE is attempted
enqueue({ data: { accounting_method: 'accrual' }, error: null })
// Engine returns null because no fiscal period covers 2099-06-01
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue(null)
// Rollback: delete the orphan invoice (items cascade)
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-NOFY',
invoice_date: '2099-06-01',
due_date: '2099-07-01',
items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
// The orphan must be rolled back: the delete is the 6th queued call.
expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices')
})
it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
// Fetch supplier
enqueue({ data: supplier, error: null })
// RPC get_next_arrival_number
enqueue({ data: 8 })
// Insert invoice → unique-index violation
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
// Lookup existing row
enqueue({
data: {
id: 'existing-1',
supplier_invoice_number: 'LF-DUP',
status: 'credited',
},
error: null,
})
// Lookup credit note for the credited original
enqueue({ data: { id: 'credit-1' }, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-DUP',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { existing: { id: string; supplier_invoice_number: string; status: string; credit_note_id: string } } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER')
expect(body.error.details.existing).toEqual({
id: 'existing-1',
supplier_invoice_number: 'LF-DUP',
status: 'credited',
credit_note_id: 'credit-1',
})
})
it('returns 409 without credit_note_id when existing invoice is not credited', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
enqueue({ data: supplier, error: null })
enqueue({ data: 9 })
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
enqueue({
data: {
id: 'existing-2',
supplier_invoice_number: 'LF-DUP-2',
status: 'approved',
},
error: null,
})
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-DUP-2',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { existing: { id: string; status: string; credit_note_id: string | null } } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER')
expect(body.error.details.existing.status).toBe('approved')
expect(body.error.details.existing.credit_note_id).toBeNull()
})
it('returns generic 409 when existing row lookup races to nothing', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
enqueue({ data: supplier, error: null })
enqueue({ data: 10 })
enqueue({
data: null,
error: {
code: '23505',
message:
'duplicate key value violates unique constraint "idx_supplier_invoices_company_supplier_number"',
},
})
// Lookup returns null: the row was deleted between the failing insert and our fetch
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RACE',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { existing?: unknown } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('SI_CREATE_DUPLICATE_INVOICE_NUMBER')
expect(body.error.details?.existing).toBeNull()
})
it('falls through to 500 for non-23505 insert errors', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
enqueue({ data: supplier, error: null })
enqueue({ data: 11 })
enqueue({ data: null, error: { code: '23502', message: 'NOT NULL violation' } })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-OTHER',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
})
it('books privately-paid invoice via 2893 path for aktiebolag', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
// Fetch supplier
enqueue({ data: supplier, error: null })
// Fetch company.entity_type (paidPrivately branch)
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
// RPC get_next_arrival_number
enqueue({ data: 12 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' })
// Update invoice with payment_journal_entry_id
enqueue({ data: null, error: null })
// Insert supplier_invoice_payments row
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-001',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{
description: 'Kontorsmaterial',
quantity: 1,
unit_price: 400,
account_number: '6110',
vat_rate: 0.25,
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { payment_journal_entry_id: string; registration_journal_entry_id: null }
}>(response)
expect(status).toBe(200)
expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
expect(body.data.registration_journal_entry_id).toBeNull()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).toHaveBeenCalled()
// The classic registration path must NOT be touched.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
expect(call[5]).toBe('aktiebolag')
})
it('passes entity_type=enskild_firma so engine credits 2018', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
enqueue({ data: supplier, error: null })
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
enqueue({ data: 13 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null })
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-002',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{
description: 'Lunch klient',
quantity: 1,
unit_price: 200,
account_number: '5810',
vat_rate: 0.12,
},
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
expect(call[5]).toBe('enskild_firma')
})
it('persists manual vat_amount override on items and forwards it to the engine', async () => {
// Bilförmån-fallet: leverantören tar 25% moms men endast 50% är
// avdragsgill. Användaren skriver 1 250 kr i momsrutan i stället för
// den beräknade 2 500 kr.
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 7 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LEAS-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{
description: 'Leasing personbil',
amount: 10000,
account_number: '5615',
vat_rate: 0.25,
vat_amount: 1250,
},
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
const items = mockCreateSupplierInvoiceRegistrationEntry.mock.calls[0][4] as Array<{
vat_amount: number
vat_rate: number
line_total: number
}>
expect(items).toHaveLength(1)
expect(items[0].vat_amount).toBe(1250)
expect(items[0].vat_rate).toBe(0.25)
expect(items[0].line_total).toBe(10000)
})
it('falls back to line_total × rate when vat_amount is omitted', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 8 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{
description: 'Material',
amount: 10000,
account_number: '4010',
vat_rate: 0.25,
},
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const items = mockCreateSupplierInvoiceRegistrationEntry.mock.calls[0][4] as Array<{
vat_amount: number
}>
expect(items[0].vat_amount).toBe(2500)
})
it('rejects periodisering combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RC-ACC',
invoice_date: '2026-01-01',
due_date: '2026-02-01',
reverse_charge: true,
items: [
{
description: 'Licens 12 mån',
amount: 12000,
account_number: '6540',
vat_rate: 0,
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_ACCRUAL_REVERSE_CHARGE')
// The guard must fire before anything is persisted or booked.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
})
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RC',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
paid_with_private_funds: true,
reverse_charge: true,
items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6540', vat_rate: 0.25 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
// Make sure we never touched the engine paths.
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})