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 } +}