Files
accounted/app/api/invoices/__tests__/route.test.ts
T
MattssonandClaude Opus 4.7 5725c25bf1 Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate

New MCP tool gnubok_create_transactions stages 1–10 transactions per call
as pending_operations of type create_transaction (risk: medium). Each item
becomes its own card on /pending; on confirm, the executor inserts the row
into transactions with import_source='mcp' so MCP-staged ingestion is
distinguishable from PSD2 sync. Designed for skill workflows that pull
external data (e.g., Airtable) and want the user to gate the writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bas): strip concatenated group headers from corrupted account names

A chart-data import bug had glued the next group's header onto the last
account in each preceding group across all eight bas-data class files
(e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27
PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names
surface in transaction dropdowns, ledgers, SIE exports and årsredovisning,
and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet
(6999) accounts specifically.

- Cleans 69 account_name and 64 description fields across class-1..8 files
- Adds a regression test asserting no name contains a concatenated header
- Ships an idempotent safety-net migration that updates already-seeded
  chart_of_accounts rows, gated on the corrupted string so user
  customizations are preserved

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(errors): add structured error codes and handling for various operations

- Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application.
- Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors.
- Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints.
- Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations.
- Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping.
- Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry.

* Refactor supplier API routes to use context-based logging and error handling

- Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`.
- Enhanced error handling to provide structured error responses for supplier creation and listing.
- Updated logging to include request IDs for better traceability.
- Introduced new error codes for supplier-related operations.
- Refactored tax deadlines cron job to utilize context and improved error handling.
- Updated ESLint configuration to enforce logging practices across API and lib directories.
- Enhanced arcim migration extension with structured error handling and logging.
- Added classification for provider errors to improve user-facing error messages.
- Introduced request ID in extension context for better log correlation.

* fix(route-context): update DynamicParams type for improved type safety in route handlers

* feat(transactions): add 'create_transaction' operation to PendingOperationType

* fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function

* fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:12:02 +02:00

423 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
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 mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn()
const mockGetAvailableVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
calculateTotal: vi.fn(),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn().mockResolvedValue(null),
convertToSEK: vi.fn(),
}))
const mockCreateCreditNoteJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createCreditNoteJournalEntry: (...args: unknown[]) =>
mockCreateCreditNoteJournalEntry(...args),
}))
import { GET, POST } from '../route'
describe('GET /api/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/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns invoices list', async () => {
const invoices = [makeInvoice(), makeInvoice()]
enqueue({ data: invoices, error: null, count: 2 })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(invoices)
expect(body.count).toBe(2)
})
it('applies status filter', async () => {
enqueue({ data: [], error: null, count: 0 })
const request = createMockRequest('/api/invoices', {
searchParams: { status: 'sent' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
})
it('applies pagination', async () => {
enqueue({ data: [], error: null, count: 0 })
const request = createMockRequest('/api/invoices', {
searchParams: { limit: '10', offset: '20' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'DB error' } })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
// GET passes through errorResponse which maps unknown DB errors to INTERNAL_ERROR
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'
describe('POST /api/invoices (create invoice)', () => {
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/invoices', {
method: 'POST',
body: { customer_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 404 when customer not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID_2,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
},
})
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('INVOICE_CUSTOMER_NOT_FOUND')
})
it('creates invoice with items and emits event', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1' })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// Insert invoice (no number generated for drafts — assigned at send time)
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch complete invoice
enqueue({ data: { ...createdInvoice, customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('rolls back invoice when items insertion fails', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1' })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
enqueue({ data: customer, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion fails
enqueue({ data: null, error: { message: 'Items insert failed' } })
// Rollback delete
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
},
})
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('INVOICE_CREATE_ITEMS_FAILED')
})
})
describe('POST /api/invoices (create credit note)', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 404 when original invoice not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID_2 },
})
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('INVOICE_CREDIT_ORIGINAL_NOT_FOUND')
})
it('returns 400 when invoice is already credited', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'credited' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_ALREADY_CREDITED')
})
it('returns 400 when invoice is in draft status', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'draft' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('creates credit note with negated amounts and emits event', async () => {
const items = [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Consulting',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
]
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
items,
})
const creditNote = makeInvoice({
id: 'cn-1',
credited_invoice_id: VALID_UUID,
subtotal: -10000,
vat_amount: -2500,
total: -12500,
status: 'sent',
})
// Fetch original invoice
enqueue({ data: original, error: null })
// Insert credit note
enqueue({ data: creditNote, error: null })
// Insert credit note items
enqueue({ data: null, error: null })
// Update original status to 'credited'
enqueue({ data: null, error: null })
// Fetch complete credit note
enqueue({ data: { ...creditNote, items: [] }, error: null })
// Fetch company settings for entity type
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update credit note with journal_entry_id
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'credit_note.created' })
)
})
it('rolls back credit note when items insertion fails', async () => {
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
items: [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Test',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2024-06-15T14:30:00Z',
},
],
})
const creditNote = makeInvoice({ id: 'cn-1' })
enqueue({ data: original, error: null })
enqueue({ data: creditNote, error: null })
// Items fail
enqueue({ data: null, error: { message: 'Items insert failed' } })
// Rollback delete
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
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('INVOICE_CREATE_ITEMS_FAILED')
})
})