From 2f83f232c67d74bcbc57aa94b5fb50763f2ff125 Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 20 Feb 2026 16:12:51 +0100 Subject: [PATCH] test: add API route tests for 10 critical endpoints Add test coverage for invoices, transactions, bookkeeping, and supplier invoice API routes. Includes test helpers (createMockRequest, parseJsonResponse, createMockRouteParams, createQueuedMockSupabase) and fixture factories (makeInvoice, makeCustomer, makeSupplier, makeSupplierInvoice, makeCompanySettings). Co-Authored-By: Claude Opus 4.6 --- .../[id]/reverse/__tests__/route.test.ts | 78 ++++ .../journal-entries/__tests__/route.test.ts | 156 +++++++ .../[id]/mark-paid/__tests__/route.test.ts | 185 ++++++++ .../[id]/send/__tests__/route.test.ts | 241 +++++++++++ app/api/invoices/__tests__/route.test.ts | 394 ++++++++++++++++++ .../[id]/approve/__tests__/route.test.ts | 75 ++++ .../[id]/mark-paid/__tests__/route.test.ts | 255 ++++++++++++ .../supplier-invoices/__tests__/route.test.ts | 246 +++++++++++ .../[id]/categorize/__tests__/route.test.ts | 262 ++++++++++++ .../match-invoice/__tests__/route.test.ts | 233 +++++++++++ tests/helpers.ts | 310 ++++++++++++++ 11 files changed, 2435 insertions(+) create mode 100644 app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts create mode 100644 app/api/bookkeeping/journal-entries/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/mark-paid/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/send/__tests__/route.test.ts create mode 100644 app/api/invoices/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/categorize/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/match-invoice/__tests__/route.test.ts diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts new file mode 100644 index 00000000..c6d812b0 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + makeJournalEntry, +} from '@/tests/helpers' + +// Mock dependencies before imports +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => mockCreateClient(), +})) + +const mockReverseEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }, + }) + }) + + it('returns 401 when not authenticated', async () => { + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, + }) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', { + method: 'POST', + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns reversed entry on success', async () => { + const reversalEntry = makeJournalEntry({ + id: 'reversal-1', + reverses_id: 'entry-1', + source_type: 'storno', + }) + mockReverseEntry.mockResolvedValue(reversalEntry) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', { + method: 'POST', + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(reversalEntry) + expect(mockReverseEntry).toHaveBeenCalledWith('user-1', 'entry-1') + }) + + it('returns 400 when engine throws', async () => { + mockReverseEntry.mockRejectedValue(new Error('Entry already reversed')) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', { + method: 'POST', + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Entry already reversed') + }) +}) diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts new file mode 100644 index 00000000..93e4dabb --- /dev/null +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeJournalEntry, +} from '@/tests/helpers' + +// Mock dependencies +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), +})) + +import { GET, POST } from '../route' + +describe('GET /api/bookkeeping/journal-entries', () => { + 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/bookkeeping/journal-entries') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns entries list', async () => { + const entries = [makeJournalEntry(), makeJournalEntry()] + enqueue({ data: entries, error: null, count: 2 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(entries) + expect(body.count).toBe(2) + }) + + it('passes filters to query', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { + period_id: 'period-1', + status: 'posted', + date_from: '2024-01-01', + date_to: '2024-12-31', + limit: '10', + offset: '5', + }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries') + }) + + it('returns 500 on database error', async () => { + enqueue({ data: null, error: { message: 'DB error' } }) + + const request = createMockRequest('/api/bookkeeping/journal-entries') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('DB error') + }) +}) + +describe('POST /api/bookkeeping/journal-entries', () => { + 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/bookkeeping/journal-entries', { + method: 'POST', + body: {}, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('creates journal entry and returns it', async () => { + const entry = makeJournalEntry() + mockCreateJournalEntry.mockResolvedValue(entry) + + const input = { + fiscal_period_id: 'period-1', + entry_date: '2024-06-15', + description: 'Test entry', + source_type: 'manual', + lines: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ], + } + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + method: 'POST', + body: input, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(entry) + expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', input) + }) + + it('returns 400 when engine throws', async () => { + mockCreateJournalEntry.mockRejectedValue(new Error('Unbalanced entry')) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + method: 'POST', + body: { + fiscal_period_id: 'period-1', + entry_date: '2024-06-15', + description: 'Bad entry', + source_type: 'manual', + lines: [{ account_number: '1930', debit_amount: 1000, credit_amount: 0 }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Unbalanced entry') + }) +}) diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..bb2198d5 --- /dev/null +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeInvoice, + makeCustomer, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateInvoicePaymentJournalEntry = vi.fn() +const mockCreateInvoiceCashEntry = vi.fn() +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoicePaymentJournalEntry: (...args: unknown[]) => + mockCreateInvoicePaymentJournalEntry(...args), + createInvoiceCashEntry: (...args: unknown[]) => + mockCreateInvoiceCashEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/invoices/[id]/mark-paid', () => { + 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/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Fakturan hittades inte') + }) + + it('returns 400 when invoice is in draft status', async () => { + const invoice = makeInvoice({ status: 'draft' }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + }) + + it('returns 400 when invoice is already paid', async () => { + const invoice = makeInvoice({ status: 'paid' }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + }) + + it('returns 400 when invoice is credited', async () => { + const invoice = makeInvoice({ status: 'credited' }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + }) + + it('marks sent invoice as paid with accrual method', async () => { + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + total: 12500, + customer, + }) + + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Update invoice status + enqueue({ data: null, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('paid') + expect(body.paid_amount).toBe(12500) + expect(body.journal_entry_id).toBe('je-1') + expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + expect.any(String) + ) + }) + + it('marks overdue invoice as paid with cash method', async () => { + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'overdue', + total: 12500, + customer, + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBe('je-2') + expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + expect.any(String), + 'enskild_firma' + ) + }) + + it('returns success with null journal_entry_id when journal entry creation fails', async () => { + const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBeNull() + }) +}) diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts new file mode 100644 index 00000000..1a8a8657 --- /dev/null +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeInvoice, + makeCustomer, + makeCompanySettings, +} 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(), +})) + +const mockRenderToBuffer = vi.fn() +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args), + Document: vi.fn(), + Page: vi.fn(), + Text: vi.fn(), + View: vi.fn(), + StyleSheet: { create: (s: unknown) => s }, +})) + +vi.mock('@/lib/invoice/pdf-template', () => ({ + InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), +})) + +const mockSendEmail = vi.fn() +const mockIsResendConfigured = vi.fn() +vi.mock('@/lib/email/resend', () => ({ + sendEmail: (...args: unknown[]) => mockSendEmail(...args), + isResendConfigured: () => mockIsResendConfigured(), +})) + +vi.mock('@/lib/email/invoice-templates', () => ({ + generateInvoiceEmailHtml: vi.fn().mockReturnValue('Invoice'), + generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'), + generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-2024001'), +})) + +const mockCreateInvoiceJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoiceJournalEntry: (...args: unknown[]) => + mockCreateInvoiceJournalEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/invoices/[id]/send', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' }) + const company = makeCompanySettings({ accounting_method: 'accrual' }) + const invoice = makeInvoice({ + id: 'inv-1', + status: 'draft', + customer, + items: [ + { + id: 'item-1', + invoice_id: 'inv-1', + sort_order: 0, + description: 'Consulting', + quantity: 10, + unit: 'tim', + unit_price: 1000, + line_total: 10000, + created_at: '2024-06-15T14:30:00Z', + }, + ], + }) + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockIsResendConfigured.mockReturnValue(true) + mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 503 when email service is not configured', async () => { + mockIsResendConfigured.mockReturnValue(false) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(503) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Fakturan hittades inte') + }) + + it('returns 400 when customer has no email', async () => { + const noEmailInvoice = makeInvoice({ + id: 'inv-1', + customer: makeCustomer({ email: null }), + items: [], + }) + enqueue({ data: noEmailInvoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('e-postadress') + }) + + it('returns 404 when company settings not found', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Företagsinställningar saknas') + }) + + it('sends invoice email, updates status, creates journal entry for accrual', async () => { + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Fetch company settings + enqueue({ data: company, error: null }) + + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' }) + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Update invoice status to 'sent' + enqueue({ data: null, error: null }) + // Update invoice with journal_entry_id + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + messageId: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.messageId).toBe('msg-1') + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kund@test.se', + subject: 'Faktura F-2024001', + }) + ) + expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + 'enskild_firma' + ) + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'invoice.sent' }) + ) + }) + + it('skips journal entry for cash method', async () => { + const cashCompany = makeCompanySettings({ accounting_method: 'cash' }) + enqueue({ data: invoice, error: null }) + enqueue({ data: cashCompany, error: null }) + + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' }) + + // Update invoice status + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('does not fail when journal entry creation fails (non-blocking)', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-3' }) + mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked')) + + // Update invoice status + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('returns 500 when email sending fails', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + + mockSendEmail.mockResolvedValue({ success: false, error: 'SMTP error' }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toContain('SMTP error') + }) +}) diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts new file mode 100644 index 00000000..7a1a1322 --- /dev/null +++ b/app/api/invoices/__tests__/route.test.ts @@ -0,0 +1,394 @@ +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(), +})) + +const mockGetVatRules = vi.fn() +const mockCalculateVat = vi.fn() +vi.mock('@/lib/invoice/vat-rules', () => ({ + getVatRules: (...args: unknown[]) => mockGetVatRules(...args), + calculateVat: (...args: unknown[]) => mockCalculateVat(...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) + expect(body.error).toBe('DB error') + }) +}) + +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: 'cust-1', 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: 'cust-999', + 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).toBe('Customer not found') + }) + + it('creates invoice with items and emits event', async () => { + const customer = makeCustomer({ id: 'cust-1' }) + const createdInvoice = makeInvoice({ id: 'inv-1' }) + + mockGetVatRules.mockReturnValue({ + treatment: 'standard_25', + rate: 25, + momsRuta: '10', + reverseChargeText: null, + }) + mockCalculateVat.mockReturnValue(2500) + + // Fetch customer + enqueue({ data: customer, error: null }) + // RPC generate_invoice_number + enqueue({ data: 'F-2024001' }) + // Insert invoice + 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: 'cust-1', + 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: 'cust-1' }) + const createdInvoice = makeInvoice({ id: 'inv-1' }) + + mockGetVatRules.mockReturnValue({ + treatment: 'standard_25', + rate: 25, + momsRuta: '10', + reverseChargeText: null, + }) + mockCalculateVat.mockReturnValue(2500) + + enqueue({ data: customer, error: null }) + enqueue({ data: 'F-2024001' }) + 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: 'cust-1', + 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).toBe('Items insert 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: 'inv-999' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Original invoice not found') + }) + + it('returns 400 when invoice is already credited', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'credited' }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Invoice has already been credited') + }) + + it('returns 400 when invoice is in draft status', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'draft' }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Only sent, paid, or overdue invoices can be credited') + }) + + 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, + created_at: '2024-06-15T14:30:00Z', + }, + ] + const original = makeInvoice({ + id: 'inv-1', + status: 'sent', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + items, + }) + const creditNote = makeInvoice({ + id: 'cn-1', + credited_invoice_id: 'inv-1', + 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: 'inv-1' }, + }) + 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: 'inv-1', + 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, + 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: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('Items insert failed') + }) +}) diff --git a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts new file mode 100644 index 00000000..ce637037 --- /dev/null +++ b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeSupplierInvoice, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +import { POST } from '../route' + +describe('POST /api/supplier-invoices/[id]/approve', () => { + 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/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Not found') + }) + + it('returns 400 when invoice is not in registered status', async () => { + enqueue({ data: { status: 'approved' }, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Kan bara godkänna registrerade fakturor') + }) + + it('approves registered invoice', async () => { + const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' }) + const approvedInvoice = { ...invoice, status: 'approved' } + + // First call: fetch invoice status + enqueue({ data: { status: 'registered' }, error: null }) + // Second call: update + select + enqueue({ data: approvedInvoice, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(approvedInvoice) + }) +}) diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..e0661b7f --- /dev/null +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeSupplierInvoice, + makeSupplier, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateSupplierInvoicePaymentEntry = vi.fn() +const mockCreateSupplierInvoiceCashEntry = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoicePaymentEntry: (...args: unknown[]) => + mockCreateSupplierInvoicePaymentEntry(...args), + createSupplierInvoiceCashEntry: (...args: unknown[]) => + mockCreateSupplierInvoiceCashEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/supplier-invoices/[id]/mark-paid', () => { + 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/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/supplier-invoices/si-999/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-999' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Not found') + }) + + it('returns 400 when invoice is in wrong status', async () => { + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'paid', + supplier: makeSupplier(), + items: [], + }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + }) + + it('marks as fully paid with accrual method', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + + // Update invoice + enqueue({ data: null, error: null }) + // Record payment + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + remaining_amount: number + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('paid') + expect(body.paid_amount).toBe(10000) + expect(body.remaining_amount).toBe(0) + expect(body.journal_entry_id).toBe('je-1') + expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled() + }) + + it('marks as partially paid', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-2' }) + + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: { amount: 5000 }, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + remaining_amount: number + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('partially_paid') + expect(body.paid_amount).toBe(5000) + expect(body.remaining_amount).toBe(5000) + }) + + it('uses cash method journal entry when configured', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [ + { + id: 'item-1', + supplier_invoice_id: 'si-1', + sort_order: 0, + description: 'Material', + quantity: 10, + unit: 'st', + unit_price: 800, + line_total: 8000, + account_number: '4010', + vat_code: null, + vat_rate: 0.25, + vat_amount: 2000, + created_at: '2024-06-01T00:00:00Z', + }, + ], + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-3' }) + + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.journal_entry_id).toBe('je-3') + expect(mockCreateSupplierInvoiceCashEntry).toHaveBeenCalled() + expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() + }) + + it('returns success when journal entry creation fails (non-blocking)', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockRejectedValue(new Error('Period locked')) + + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBeNull() + }) +}) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts new file mode 100644 index 00000000..1c9d3c9a --- /dev/null +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -0,0 +1,246 @@ +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), +})) + +const mockFindFiscalPeriod = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), +})) + +const mockCreateSupplierInvoiceRegistrationEntry = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoiceRegistrationEntry: (...args: unknown[]) => + mockCreateSupplierInvoiceRegistrationEntry(...args), +})) + +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('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).toBe('DB error') + }) +}) + +describe('POST /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', { + method: 'POST', + body: { supplier_id: 'sup-1', 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 supplier not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: 'sup-999', + 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).toBe('Supplier not found') + }) + + it('creates supplier invoice with items and arrival number', async () => { + const supplier = makeSupplier({ id: 'sup-1' }) + 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: 'sup-1', + 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('skips registration entry for cash method', async () => { + const supplier = makeSupplier({ id: 'sup-1' }) + 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: 'sup-1', + 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: 'sup-1' }) + 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: 'sup-1', + 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).toBe('Items insert failed') + }) +}) diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts new file mode 100644 index 00000000..4246c696 --- /dev/null +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, +} 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(), +})) + +const mockBuildMappingResultFromCategory = vi.fn() +vi.mock('@/lib/bookkeeping/category-mapping', () => ({ + buildMappingResultFromCategory: (...args: unknown[]) => + mockBuildMappingResultFromCategory(...args), +})) + +const mockCreateTransactionJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => + mockCreateTransactionJournalEntry(...args), +})) + +const mockSaveUserMappingRule = vi.fn() +vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ + saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args), +})) + +import { POST } from '../route' + +describe('POST /api/transactions/[id]/categorize', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const defaultMappingResult = { + rule: null, + debit_account: '6200', + credit_account: '1930', + risk_level: 'NONE', + confidence: 1, + requires_review: false, + default_private: false, + vat_lines: [{ account_number: '2641', debit_amount: 62.5, credit_amount: 0, description: 'Ingående moms' }], + description: 'Test expense', + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockBuildMappingResultFromCategory.mockReturnValue(defaultMappingResult) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Transaction not found') + }) + + it('updates category only when transaction already has journal entry', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: 'je-existing', + category: 'uncategorized', + }) + // Fetch transaction + enqueue({ data: tx, error: null }) + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + already_had_journal_entry: boolean + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.already_had_journal_entry).toBe(true) + expect(body.journal_entry_id).toBe('je-existing') + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) + + it('creates journal entry for business expense', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'GitHub', + journal_entry_id: null, + }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // Fetch company settings + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + // ensureFiscalPeriod: check existing + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockSaveUserMappingRule.mockResolvedValue(undefined) + + // Update transaction + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + journal_entry_id: string + category: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(true) + expect(body.journal_entry_id).toBe('je-1') + expect(body.category).toBe('expense_software') + expect(mockSaveUserMappingRule).toHaveBeenCalledWith( + 'user-1', + 'GitHub', + '6200', + '1930', + false + ) + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'transaction.categorized' }) + ) + }) + + it('returns success with error when journal entry creation fails (non-blocking)', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'Test', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked')) + + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + journal_entry_error: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(false) + expect(body.journal_entry_error).toBe('Period locked') + }) + + it('returns 500 when transaction update fails', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + merchant_name: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Transaction update fails + enqueue({ data: null, error: { message: 'Update failed' } }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('Failed to update transaction') + }) + + it('categorizes as private when is_business is false', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + merchant_name: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: false }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + category: string + }>(response) + + expect(status).toBe(200) + expect(body.category).toBe('private') + // Should NOT save mapping rule for private transactions + expect(mockSaveUserMappingRule).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts new file mode 100644 index 00000000..92cee214 --- /dev/null +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, + makeInvoice, + makeCustomer, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateJournalEntry = vi.fn() +const mockFindFiscalPeriod = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), +})) + +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + getRevenueAccount: vi.fn().mockReturnValue('3001'), + getOutputVatAccount: vi.fn().mockReturnValue('2611'), +})) + +import { POST } from '../route' + +describe('POST /api/transactions/[id]/match-invoice', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFindFiscalPeriod.mockResolvedValue('period-1') + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when invoice_id is missing', async () => { + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('invoice_id is required') + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Transaction not found') + }) + + it('returns 400 when transaction is an expense (amount <= 0)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: -500 }) + enqueue({ data: tx, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Only income transactions can be matched to invoices') + }) + + it('returns 400 when transaction is already linked to an invoice', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: 'inv-other' }) + enqueue({ data: tx, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Transaction is already linked to an invoice') + }) + + it('returns 404 when invoice not found', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + enqueue({ data: tx, error: null }) + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-999' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Invoice not found') + }) + + it('returns 400 when invoice is not in unpaid state', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + const invoice = makeInvoice({ id: 'inv-1', status: 'paid' }) + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Invoice is not in an unpaid state') + }) + + it('matches transaction to invoice with accrual method', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + total: 12500, + subtotal: 10000, + vat_amount: 2500, + invoice_number: 'F-2024001', + customer, + }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Update invoice to paid + enqueue({ data: null, error: null }) + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + invoice_status: string + paid_amount: number + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.invoice_status).toBe('paid') + expect(body.paid_amount).toBe(12500) + expect(body.journal_entry_id).toBe('je-1') + + // Verify accrual journal entry: debit 1930, credit 1510 + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + source_type: 'invoice_paid', + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12500 }), + ]), + }) + ) + }) + + it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateJournalEntry.mockRejectedValue(new Error('Period locked')) + + // Update invoice + enqueue({ data: null, error: null }) + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: null + journal_entry_error: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBeNull() + expect(body.journal_entry_error).toBe('Period locked') + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 42d57301..100c7163 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -10,6 +10,11 @@ import type { JournalEntryLine, DocumentAttachment, TaxCode, + Invoice, + Customer, + Supplier, + SupplierInvoice, + CompanySettings, } from '@/types' // ============================================================ @@ -267,3 +272,308 @@ export function makeTaxCode(overrides: Partial = {}): TaxCode { ...overrides, } } + +export function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: nextId(), + user_id: 'user-1', + customer_id: 'customer-1', + invoice_number: 'F-2024001', + invoice_date: '2024-06-15', + due_date: '2024-07-15', + status: 'draft', + currency: 'SEK', + exchange_rate: null, + exchange_rate_date: null, + subtotal: 10000, + subtotal_sek: null, + vat_amount: 2500, + vat_amount_sek: null, + total: 12500, + total_sek: null, + vat_treatment: 'standard_25', + vat_rate: 25, + moms_ruta: '10', + your_reference: null, + our_reference: null, + notes: null, + reverse_charge_text: null, + credited_invoice_id: null, + paid_at: null, + paid_amount: null, + created_at: '2024-06-15T14:30:00Z', + updated_at: '2024-06-15T14:30:00Z', + ...overrides, + } +} + +export function makeCustomer(overrides: Partial = {}): Customer { + return { + id: nextId(), + user_id: 'user-1', + name: 'Test AB', + customer_type: 'swedish_business', + email: 'kontakt@test.se', + phone: null, + address_line1: 'Storgatan 1', + address_line2: null, + postal_code: '111 22', + city: 'Stockholm', + country: 'SE', + org_number: '5566778899', + vat_number: 'SE556677889901', + vat_number_validated: true, + vat_number_validated_at: '2024-01-01T00:00:00Z', + default_payment_terms: 30, + notes: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + } +} + +export function makeSupplier(overrides: Partial = {}): Supplier { + return { + id: nextId(), + user_id: 'user-1', + name: 'Leverantör AB', + supplier_type: 'swedish_business', + email: 'info@leverantor.se', + phone: null, + address_line1: 'Industrivägen 5', + address_line2: null, + postal_code: '123 45', + city: 'Göteborg', + country: 'SE', + org_number: '5599887766', + vat_number: null, + bankgiro: '123-4567', + plusgiro: null, + bank_account: null, + iban: null, + bic: null, + default_expense_account: '6200', + default_payment_terms: 30, + default_currency: 'SEK', + notes: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + } +} + +export function makeSupplierInvoice( + overrides: Partial = {} +): SupplierInvoice { + return { + id: nextId(), + user_id: 'user-1', + supplier_id: 'supplier-1', + arrival_number: 1, + supplier_invoice_number: 'LF-001', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + received_date: '2024-06-02', + delivery_date: null, + status: 'registered', + currency: 'SEK', + exchange_rate: null, + exchange_rate_date: null, + subtotal: 8000, + subtotal_sek: null, + vat_amount: 2000, + vat_amount_sek: null, + total: 10000, + total_sek: null, + vat_treatment: 'standard_25', + reverse_charge: false, + payment_reference: null, + paid_at: null, + paid_amount: 0, + remaining_amount: 10000, + is_credit_note: false, + credited_invoice_id: null, + registration_journal_entry_id: null, + payment_journal_entry_id: null, + transaction_id: null, + document_id: null, + notes: null, + created_at: '2024-06-02T00:00:00Z', + updated_at: '2024-06-02T00:00:00Z', + ...overrides, + } +} + +export function makeCompanySettings( + overrides: Partial = {} +): CompanySettings { + return { + id: nextId(), + user_id: 'user-1', + entity_type: 'enskild_firma', + company_name: 'Test Firma', + org_number: '199001011234', + address_line1: 'Testgatan 1', + address_line2: null, + postal_code: '111 22', + city: 'Stockholm', + country: 'SE', + f_skatt: true, + vat_registered: true, + vat_number: null, + moms_period: 'quarterly', + fiscal_year_start_month: 1, + preliminary_tax_monthly: null, + bank_name: null, + clearing_number: null, + account_number: null, + iban: null, + bic: null, + accounting_method: 'accrual', + invoice_prefix: 'F', + next_invoice_number: 1, + invoice_default_days: 30, + onboarding_step: 6, + onboarding_complete: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + } +} + +// ============================================================ +// API Route Test Helpers +// ============================================================ + +/** + * Build a Request object for testing Next.js API route handlers. + */ +export function createMockRequest( + url: string, + options?: { + method?: string + body?: unknown + searchParams?: Record + } +): Request { + const fullUrl = new URL(url, 'http://localhost:3000') + if (options?.searchParams) { + for (const [key, value] of Object.entries(options.searchParams)) { + fullUrl.searchParams.set(key, value) + } + } + return new Request(fullUrl.toString(), { + method: options?.method || 'GET', + headers: { 'Content-Type': 'application/json' }, + ...(options?.body ? { body: JSON.stringify(options.body) } : {}), + }) +} + +/** + * Parse NextResponse to {status, body}. + */ +export async function parseJsonResponse( + response: Response +): Promise<{ status: number; body: T }> { + const body = (await response.json()) as T + return { status: response.status, body } +} + +/** + * Build Promise-based params for Next.js 16 dynamic routes. + */ +export function createMockRouteParams>( + params: T +): { params: Promise } { + return { params: Promise.resolve(params) } +} + +/** + * Queue-based Supabase mock for routes with multiple sequential DB calls. + * + * Each call to `.from()` or `.rpc()` consumes the next result in the queue. + */ +export function createQueuedMockSupabase() { + const queue: { data: unknown; error: unknown; count?: number | null }[] = [] + + const enqueue = (result: { + data?: unknown + error?: unknown + count?: number | null + }) => { + queue.push({ + data: result.data ?? null, + error: result.error ?? null, + count: result.count ?? null, + }) + } + + const enqueueMany = ( + results: { data?: unknown; error?: unknown; count?: number | null }[] + ) => { + for (const r of results) { + enqueue(r) + } + } + + const reset = () => { + queue.length = 0 + } + + const buildChain = (): unknown => { + // Capture the result at chain creation (when from/rpc is called) + const result = queue.shift() || { data: null, error: null, count: null } + + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return (..._args: unknown[]) => buildChain2(result) + }, + } + return new Proxy({}, handler) + } + + // Inner chain methods reuse the same result + const buildChain2 = (result: { + data: unknown + error: unknown + count?: number | null + }): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return (..._args: unknown[]) => buildChain2(result) + }, + } + return new Proxy({}, handler) + } + + const storageMock = { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ data: {}, error: null }), + download: vi.fn().mockResolvedValue({ + data: new Blob(['test']), + error: null, + }), + remove: vi.fn().mockResolvedValue({ data: [], error: null }), + getPublicUrl: vi.fn().mockReturnValue({ + data: { publicUrl: 'https://example.com/file.jpg' }, + }), + }), + } + + const supabase = { + from: vi.fn().mockImplementation(() => buildChain()), + rpc: vi.fn().mockImplementation(() => buildChain()), + storage: storageMock, + auth: { + getUser: vi.fn(), + }, + } + + return { supabase, enqueue, enqueueMany, reset } +}