import { describe, it, expect, beforeEach, vi } from 'vitest' import { createMockRequest, parseJsonResponse, createQueuedMockSupabase, } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) }, })) vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn(), })) vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) // applyTemplate is the pure ratio/VAT expander used by the route. The // route test stubs it so we don't need a real template object. vi.mock('@/lib/bookkeeping/template-library', () => ({ applyTemplate: vi.fn(), })) import { POST } from '../route' import { applyTemplate } from '@/lib/bookkeeping/template-library' const TX1 = '11111111-1111-4111-8111-111111111111' const TX2 = '22222222-2222-4222-8222-222222222222' const TPL = '33333333-3333-4333-8333-333333333333' const JE = '44444444-4444-4444-8444-444444444444' describe('POST /api/transactions/bulk-book', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) }) it('returns 400 when neither template_id nor existing_journal_entry_id is set', async () => { const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1] }, }) const response = await POST(request) expect(response.status).toBe(400) }) it('returns 400 when both template_id and existing_journal_entry_id are set', async () => { const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1], template_id: TPL, existing_journal_entry_id: JE, mode: 'one_line_per_tx', entry_description: 'Test', }, }) const response = await POST(request) expect(response.status).toBe(400) }) it('link path passes through to RPC and returns the success envelope', async () => { // Currency gate tx fetch (hoisted: runs on every path). enqueue({ data: [ { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, ], error: null, }) // RPC returns the link-existing happy path. enqueue({ data: { ok: true, mode: 'link_existing', journal_entry_id: JE, voucher_series: 'A', voucher_number: 12, linked_tx_count: 2, tx_sum: 300, }, error: null, }) // Event re-fetch (empty is fine for the test). enqueue({ data: [], error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE, }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ data: { mode: string; journal_entry_id: string; linked_tx_count: number } }>(response) expect(status).toBe(200) expect(body.data.mode).toBe('link_existing') expect(body.data.journal_entry_id).toBe(JE) expect(body.data.linked_tx_count).toBe(2) }) it('create-new path fetches template, expands per mode, and calls RPC', async () => { // Tx fetch (hoisted for the currency gate): 2 incomes totalling 300. enqueue({ data: [ { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, ], error: null, }) // Template fetch. enqueue({ data: { id: TPL, name: 'Försäljning 25%', lines: [ { account: '1930', label: 'Bank', side: 'debit', type: 'settlement' }, { account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 0.8 }, { account: '2611', label: 'Utg moms 25%', side: 'credit', type: 'vat', vat_rate: 0.25 }, ], is_active: true, }, error: null, }) // applyTemplate stub: return a balanced 3-line set per call. vi.mocked(applyTemplate).mockImplementation((_lines, total) => [ { account_number: '1930', debit_amount: String(total), credit_amount: '', line_description: 'Bank' }, { account_number: '3001', debit_amount: '', credit_amount: String(total * 0.8), line_description: 'Försäljning' }, { account_number: '2611', debit_amount: '', credit_amount: String(total * 0.2), line_description: 'Utg moms 25%' }, ]) // Account dimension rules pre-check (PR10) — none configured. enqueue({ data: [], error: null }) // RPC returns happy path. enqueue({ data: { ok: true, mode: 'create_new', journal_entry_id: JE, voucher_series: 'A', voucher_number: 13, linked_tx_count: 2, tx_sum: 300, }, error: null, }) // Event re-fetch. enqueue({ data: [], error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], template_id: TPL, mode: 'one_line_per_tx', entry_description: 'Samlingsverifikation 2026-06-05', }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ data: { mode: string; journal_entry_id: string } }>(response) expect(status).toBe(200) expect(body.data.mode).toBe('create_new') expect(body.data.journal_entry_id).toBe(JE) // Template expansion was invoked once per tx in one_line_per_tx mode. expect(vi.mocked(applyTemplate)).toHaveBeenCalledTimes(2) }) it('maps RPC structured failure code to errorResponseFromCode', async () => { // Currency gate tx fetch. enqueue({ data: [ { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-06' }, ], error: null, }) enqueue({ data: { ok: false, code: 'BULK_BOOK_DATE_MISMATCH', details: { expected: '2026-06-05', got: '2026-06-06' } }, error: null, }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) expect(status).toBe(400) expect(body.error.code).toBe('BULK_BOOK_DATE_MISMATCH') }) }) /** * Mixed-currency guard (BFL 4 kap 6 §: one redovisningsvaluta). A * samlingsverifikation spanning SEK and EUR has no representable single * belopp, so all three request shapes must refuse it with the SAME code the * MCP twin uses. Before this, only the template branch checked; manual_lines * and existing_journal_entry_id went straight to the RPC, which summed * 100 EUR + 100 SEK into the scalar 200. */ describe('POST /api/transactions/bulk-book: mixed-currency guard', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } const MIXED_TXS = [ { id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, { id: TX2, amount: 100, currency: 'EUR', description: 'Stripe', date: '2026-06-05' }, ] beforeEach(() => { vi.clearAllMocks() reset() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) }) it('refuses a mixed-currency selection on the template branch', async () => { enqueue({ data: MIXED_TXS, error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], template_id: TPL, mode: 'sum_per_account', entry_description: 'Samlingsverifikation', }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: { code: string; details?: { currencies?: string[] } } }>(response) expect(status).toBe(400) expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY') }) it('refuses a mixed-currency selection on the manual_lines branch', async () => { enqueue({ data: MIXED_TXS, error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], entry_description: 'Samlingsverifikation', manual_lines: [ { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, ], }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) expect(status).toBe(400) expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY') // Refused before the RPC: the chart-of-accounts lookup never ran either. expect(mockSupabase.rpc).not.toHaveBeenCalled() }) it('refuses a mixed-currency selection on the existing_journal_entry_id branch', async () => { enqueue({ data: MIXED_TXS, error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) expect(status).toBe(400) expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY') expect(mockSupabase.rpc).not.toHaveBeenCalled() }) it('refuses a homogeneous non-SEK selection with BULK_BOOK_FOREIGN_CURRENCY', async () => { // Same currency throughout, but not kronor: the RPC would write the // foreign magnitudes into the always-SEK debit/credit columns, so the // route refuses before the RPC just like the mixed-currency case. enqueue({ data: [ { id: TX1, amount: 100, currency: 'EUR', description: 'Stripe 1', date: '2026-06-05' }, { id: TX2, amount: 200, currency: 'EUR', description: 'Stripe 2', date: '2026-06-05' }, ], error: null, }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], entry_description: 'Samlingsverifikation', manual_lines: [ { account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'EUR' }, { account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'EUR' }, ], }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: { code: string; details?: { currency?: string } } }>(response) expect(status).toBe(400) expect(body.error.code).toBe('BULK_BOOK_FOREIGN_CURRENCY') expect(body.error.details?.currency).toBe('EUR') expect(mockSupabase.rpc).not.toHaveBeenCalled() }) it('books a single-currency manual_lines selection', async () => { // Tx fetch: both SEK. enqueue({ data: [ { id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, { id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, ], error: null, }) // chart_of_accounts allowlist. enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }], error: null, }) // Dimension rules: none configured. enqueue({ data: [], error: null }) // RPC happy path. enqueue({ data: { ok: true, mode: 'create_new', journal_entry_id: JE, voucher_series: 'A', voucher_number: 14, linked_tx_count: 2, tx_sum: 200, }, error: null, }) // Event re-fetch. enqueue({ data: [], error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], entry_description: 'Samlingsverifikation', manual_lines: [ { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, ], }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ data: { mode: string } }>(response) expect(status).toBe(200) expect(body.data.mode).toBe('create_new') }) it('treats NULL currency as SEK and still books', async () => { enqueue({ data: [ { id: TX1, amount: 100, currency: null, description: 'Legacy row', date: '2026-06-05' }, { id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, ], error: null, }) enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }], error: null }) enqueue({ data: [], error: null }) enqueue({ data: { ok: true, mode: 'create_new', journal_entry_id: JE, voucher_series: 'A', voucher_number: 15, linked_tx_count: 2, tx_sum: 200, }, error: null, }) enqueue({ data: [], error: null }) const request = createMockRequest('/api/transactions/bulk-book', { method: 'POST', body: { tx_ids: [TX1, TX2], entry_description: 'Samlingsverifikation', manual_lines: [ { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, ], }, }) const response = await POST(request) expect(response.status).toBe(200) }) })