/** * Unit tests for the executors added to bring every declared op type up to a * callable state through `commitPendingOperation`. Tests run through the * public dispatcher (executors are not exported individually) so the wiring * is exercised alongside executor logic. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { eventBus } from '@/lib/events/bus' import { createQueuedMockSupabase, makeCustomer, makeInvoice, makeFiscalPeriod, makeSupplierInvoice, } from '@/tests/helpers' import type { PendingOperation } from '@/types' import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer' vi.mock('@/lib/core/bookkeeping/period-service', async () => { const actual = await vi.importActual( '@/lib/core/bookkeeping/period-service' ) return { ...actual, unlockPeriod: vi.fn(), } }) // create_transaction and create_invoice now resolve a Riksbanken rate for any // non-SEK row (and refuse when there is none), so the foreign-currency cases // below must not reach the real API. FX behaviour itself is covered in // staged-fx-rates.test.ts. vi.mock('@/lib/currency/riksbanken', async () => { const actual = await vi.importActual( '@/lib/currency/riksbanken' ) return { ...actual, fetchExchangeRate: vi.fn(async (currency: string) => ({ currency, rate: 10.5, date: '2026-05-01', })), } }) vi.mock('@/lib/import/sie-parser', () => ({ parseSIEFile: vi.fn(), calculateFileHash: vi.fn(async () => 'mock-hash'), })) vi.mock('@/lib/import/sie-import', () => ({ executeSIEImport: vi.fn(), })) vi.mock('@/lib/bokslut/assets/depreciation-engine', () => ({ commitAnnualPostings: vi.fn(), })) vi.mock('@/lib/bookkeeping/invoice-entries', async () => { const actual = await vi.importActual( '@/lib/bookkeeping/invoice-entries' ) return { ...actual, createCreditNoteJournalEntry: vi.fn(), } }) vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => { const actual = await vi.importActual( '@/lib/bookkeeping/supplier-invoice-entries' ) return { ...actual, createSupplierCreditNoteEntry: vi.fn(), } }) vi.mock('@/lib/transactions/categorize-core', async () => { const actual = await vi.importActual( '@/lib/transactions/categorize-core' ) return { ...actual, categorizeMatchedTransaction: vi.fn(), } }) vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { const actual = await importOriginal() return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) } }) vi.mock('@/lib/email/invoice-sender', () => ({ resolveInvoiceSender: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/email/service', () => ({ getEmailService: () => ({ isConfigured: () => true, sendEmail: vi.fn(), }), })) vi.mock('@/lib/invoices/ensure-invoice-number', () => ({ ensureInvoiceNumber: vi.fn(), })) const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' }) const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1') vi.mock('@/lib/invoices/invoice-deliveries', () => ({ recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args), reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args), sendTrackedInvoiceEmail: vi.fn(), })) import { commitPendingOperation } from '../commit' import { unlockPeriod } from '@/lib/core/bookkeeping/period-service' import { parseSIEFile } from '@/lib/import/sie-parser' import { executeSIEImport } from '@/lib/import/sie-import' import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine' import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries' import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' function makePendingOp(overrides: Partial): PendingOperation { return { id: 'op-1', user_id: 'user-1', company_id: 'company-1', operation_type: 'create_customer', status: 'pending', title: 'test', params: {}, preview_data: {}, result_data: null, actor_type: 'user', actor_id: null, actor_label: null, risk_level: 'high', created_at: '2026-05-03T00:00:00Z', resolved_at: null, updated_at: '2026-05-03T00:00:00Z', ...overrides, } as PendingOperation } beforeEach(() => { vi.clearAllMocks() eventBus.clear() }) // ─── unlock_period ────────────────────────────────────────────────── describe('commitPendingOperation: unlock_period', () => { it('happy path: clears locked_at and returns committed', async () => { const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null }) vi.mocked(unlockPeriod).mockResolvedValueOnce(period) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's pending_operations update const op = makePendingOp({ operation_type: 'unlock_period', params: { fiscal_period_id: 'fp-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ period_id: 'fp-1', locked_at: null }) expect(unlockPeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'fp-1') }) it('rejects when fiscal_period_id is missing', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'unlock_period', params: {} }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(unlockPeriod).not.toHaveBeenCalled() }) it('surfaces underlying service errors', async () => { vi.mocked(unlockPeriod).mockRejectedValueOnce(new Error('Period is not locked')) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update on throw const op = makePendingOp({ operation_type: 'unlock_period', params: { fiscal_period_id: 'fp-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.error).toMatch(/not locked/) }) }) // ─── create_customer ──────────────────────────────────────────────── describe('commitPendingOperation: create_customer', () => { it('inserts the staged customer_number', async () => { const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // company_settings read (payment-terms default) enqueue({ data: makeCustomer({ id: 'cust-1', customer_number: 'K-1001' }), error: null, }) // customers insert enqueue({ data: null, error: null }) // dispatcher's pending_operations update const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'Kund AB', customer_type: 'swedish_business', customer_number: 'K-1001', email: 'faktura@example.test', }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(findCall('customers', 'insert')?.[0]).toMatchObject({ company_id: 'company-1', name: 'Kund AB', customer_number: 'K-1001', email: 'faktura@example.test', }) }) it('rejects a staged customer_number longer than 32 characters without inserting', async () => { const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'Kund AB', customer_type: 'swedish_business', customer_number: 'X'.repeat(33), }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(result.error).toMatch(/32/) expect(findCall('customers', 'insert')).toBeUndefined() }) it('inserts customer_number as null when not staged', async () => { const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // company_settings read (payment-terms default) enqueue({ data: makeCustomer({ id: 'cust-1' }), error: null }) // customers insert enqueue({ data: null, error: null }) // dispatcher's pending_operations update const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'Kund AB', customer_type: 'swedish_business' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(findCall('customers', 'insert')?.[0]).toMatchObject({ customer_number: null }) }) // Synthetic personnummer, never a real one. Ciphertext shape enforced by // customers_personal_number_check (20260726110000). const PERSONAL_NUMBER = '19900101-1234' const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/ it('stores the staged personal_number_encrypted as the customer personal_number, with the staged payment terms', async () => { const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim // payment_terms were resolved at staging, so no company_settings read here enqueue({ data: makeCustomer({ id: 'cust-1', customer_type: 'individual' }), error: null }) // customers insert enqueue({ data: null, error: null }) // dispatcher's pending_operations update const encrypted = encryptPersonnummer(PERSONAL_NUMBER) const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'Anna Andersson', customer_type: 'individual', payment_terms: 10, personal_number_encrypted: encrypted, }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(findCall('customers', 'insert')?.[0]).toMatchObject({ personal_number: encrypted, org_number: null, default_payment_terms: 10, }) }) it('moves a personnummer staged as org_number on an individual into personal_number, encrypted', async () => { // An operation staged before gnubok_create_customer had a personal_number // input, committed after this deploy. const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // company_settings read (payment-terms default) enqueue({ data: makeCustomer({ id: 'cust-1', customer_type: 'individual' }), error: null }) // customers insert enqueue({ data: null, error: null }) // dispatcher's pending_operations update const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'Bertil Bengtsson', customer_type: 'individual', org_number: PERSONAL_NUMBER }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') const inserted = findCall('customers', 'insert')?.[0] as { org_number: string | null; personal_number: string | null } expect(inserted.org_number).toBeNull() expect(inserted.personal_number).toMatch(CIPHERTEXT_SHAPE) expect(decryptPersonnummer(inserted.personal_number!)).toBe(PERSONAL_NUMBER) expect(JSON.stringify(inserted)).not.toContain(PERSONAL_NUMBER) }) it('still refuses a personnummer-shaped org_number on a business customer', async () => { const { supabase, enqueue, findCall } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'create_customer', params: { name: 'Enskild Firma X', customer_type: 'swedish_business', org_number: PERSONAL_NUMBER }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(findCall('customers', 'insert')).toBeUndefined() }) }) describe('commitPendingOperation: credit-note issuance guard', () => { it('rejects mark_invoice_sent before the ordinary invoice executor can book it', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'credit-1', status: 'draft', credited_invoice_id: 'invoice-1', }), error: null, }) enqueue({ data: null, error: null }) // dispatcher's rejected update const op = makePendingOp({ operation_type: 'mark_invoice_sent', params: { invoice_id: 'credit-1' }, }) const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', op, ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) expect(result.error).toContain('Credit notes must be issued') }) it('records delivery history when a regular invoice is marked as sent', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: 'F-2026001', credited_invoice_id: null, }), error: null, }) enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma', bankgiro: '123-4567' }, error: null, }) enqueue({ data: null, error: null }) // status update enqueue({ data: null, error: null }) // dispatcher update const op = makePendingOp({ operation_type: 'mark_invoice_sent', params: { invoice_id: 'invoice-1' }, }) const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', op, ) expect(result.status).toBe('committed') expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({ supabase, companyId: 'company-1', userId: 'user-1', invoiceId: 'invoice-1', }) }) it.each(['SEK', 'EUR'] as const)( 'rejects a %s invoice without a payment account before number allocation', async (currency) => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: null, credited_invoice_id: null, currency, }), error: null, }) enqueue({ data: { invoice_payment_accounts: {} }, error: null }) enqueue({ data: null, error: null }) // dispatcher rejected update const op = makePendingOp({ operation_type: 'mark_invoice_sent', params: { invoice_id: 'invoice-1' }, }) const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', op, ) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(ensureInvoiceNumber).not.toHaveBeenCalled() expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled() }, ) }) describe('commitPendingOperation: invoice send payment account guard', () => { it.each(['SEK', 'EUR'] as const)( 'rejects a %s invoice before delivery reservation and number allocation', async (currency) => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: null, currency, customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }), items: [], }), error: null, }) enqueue({ data: { company_name: 'Test AB', invoice_payment_accounts: {}, }, error: null, }) enqueue({ data: null, error: null }) // dispatcher's rejected update const op = makePendingOp({ operation_type: 'send_invoice', params: { invoice_id: 'invoice-1' }, }) const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', op, ) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(ensureInvoiceNumber).not.toHaveBeenCalled() expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries') }, ) }) describe('commitPendingOperation: seller VAT number guard', () => { it('rejects mark_invoice_sent for a registered company without VAT number', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: null, credited_invoice_id: null, }), error: null, }) enqueue({ data: { bankgiro: '123-4567', vat_registered: true, vat_number: null }, error: null, }) enqueue({ data: null, error: null }) // dispatcher rejected update const op = makePendingOp({ operation_type: 'mark_invoice_sent', params: { invoice_id: 'invoice-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(ensureInvoiceNumber).not.toHaveBeenCalled() expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled() }) it('rejects send_invoice for a registered company without VAT number', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: null, customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }), items: [], }), error: null, }) enqueue({ data: { company_name: 'Test AB', bankgiro: '123-4567', vat_registered: true, vat_number: null, }, error: null, }) enqueue({ data: null, error: null }) // dispatcher's rejected update const op = makePendingOp({ operation_type: 'send_invoice', params: { invoice_id: 'invoice-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(ensureInvoiceNumber).not.toHaveBeenCalled() expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries') }) }) describe('commitPendingOperation: invoice send recipient limit', () => { it('rejects an oversized configured recipient set before reservation and allocation', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: null, customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }), items: [], }), error: null, }) enqueue({ data: { company_name: 'Test AB', bankgiro: '123-4567', invoice_email_cc_addresses: Array.from( { length: 20 }, (_, index) => `fixed-${index}@example.test`, ), invoice_email_bcc_addresses: [], }, error: null, }) enqueue({ data: null, error: null }) // dispatcher's rejected update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp({ operation_type: 'send_invoice', params: { invoice_id: 'invoice-1' }, }), ) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() expect(ensureInvoiceNumber).not.toHaveBeenCalled() }) }) // ─── post_annual_depreciation ─────────────────────────────────────── describe('commitPendingOperation: post_annual_depreciation', () => { it('happy path: routes to commitAnnualPostings and returns the posted entries', async () => { vi.mocked(commitAnnualPostings).mockResolvedValueOnce({ posted: [ { assetId: 'asset-1', entry: { id: 'je-1', voucher_number: 7 } as never, scheduleId: 'sch-1' }, ], skipped: [], }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher update const op = makePendingOp({ operation_type: 'post_annual_depreciation', params: { fiscal_period_id: 'fp-1', asset_ids: ['asset-1'] }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ posted_count: 1, skipped_count: 0, posted: [{ asset_id: 'asset-1', journal_entry_id: 'je-1', voucher_number: 7, schedule_id: 'sch-1' }], }) expect(commitAnnualPostings).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', 'fp-1', { assetIds: ['asset-1'] } ) }) it('rejects with 400 when fiscal_period_id is missing', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // reject update const op = makePendingOp({ operation_type: 'post_annual_depreciation', params: {} }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(commitAnnualPostings).not.toHaveBeenCalled() }) it('surfaces engine errors (e.g. locked period) as a failed commit', async () => { vi.mocked(commitAnnualPostings).mockRejectedValueOnce(new Error('Period is locked or closed')) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // reject update const op = makePendingOp({ operation_type: 'post_annual_depreciation', params: { fiscal_period_id: 'fp-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.error).toMatch(/locked or closed/) }) }) // ─── create_transaction ───────────────────────────────────────────── describe('commitPendingOperation: create_transaction', () => { it('happy path: inserts a transactions row with import_source=mcp and returns the id', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-42' }, error: null }) // executor insert enqueue({ data: null, error: null }) // dispatcher's update const op = makePendingOp({ operation_type: 'create_transaction', params: { date: '2026-05-01', amount: -129.5, description: 'AWS subscription', currency: 'USD', external_id: 'recAirtable123', }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ transaction_id: 'tx-42' }) }) it('rejects with 400 when required fields are missing', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'create_transaction', params: { date: '2026-05-01' }, // missing amount + description }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) }) it('returns 409 when external_id collides with an existing row', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } as never }) // executor insert enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'create_transaction', params: { date: '2026-05-01', amount: 100, description: 'test', external_id: 'recAirtable123', }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) // 409 collisions are treated as auto-rejected by the dispatcher. expect(result.status).toBe('rejected') expect(result.auto_rejected).toBe(true) expect(result.http_status).toBe(409) expect(result.error).toMatch(/already exists/) }) it('binds cash_account_id when ledger_account is given, creating the manual account', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // ensureManualCashAccount lookup miss enqueue({ data: { id: 'ca-1935' }, error: null }) // ensureManualCashAccount insert enqueue({ data: { id: 'tx-9' }, error: null }) // executor transactions insert enqueue({ data: null, error: null }) // dispatcher's update const op = makePendingOp({ operation_type: 'create_transaction', params: { date: '2026-05-01', amount: -200, description: 'WISE KORT ST', currency: 'SEK', ledger_account: '1935', }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ transaction_id: 'tx-9' }) }) it('rejects a non-19xx ledger_account with 400', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'create_transaction', params: { date: '2026-05-01', amount: -200, description: 'WISE KORT ST', ledger_account: '3001', }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(result.error).toMatch(/19xx cash account/) }) }) // ─── import_sie ───────────────────────────────────────────────────── describe('commitPendingOperation: import_sie', () => { it('happy path: parses, imports, returns committed with summary', async () => { vi.mocked(parseSIEFile).mockReturnValueOnce({} as never) vi.mocked(executeSIEImport).mockResolvedValueOnce({ success: true, importId: 'imp-1', fiscalPeriodId: 'fp-1', openingBalanceEntryId: 'ob-1', journalEntriesCreated: 5, journalEntryIds: ['je-1', 'je-2', 'je-3', 'je-4', 'je-5'], errors: [], warnings: ['minor warning'], }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's update const op = makePendingOp({ operation_type: 'import_sie', params: { file_content: '#FLAGGA 0\n', filename: 'test.sie', mappings: [], create_fiscal_period: true, import_opening_balances: true, import_transactions: true, }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ import_id: 'imp-1', journal_entries_created: 5, warnings: ['minor warning'], }) expect(parseSIEFile).toHaveBeenCalledWith('#FLAGGA 0\n') // Operations staged before update_account_names existed (params without // the key) must default to true: Boolean(undefined) would flip it off. expect(executeSIEImport).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', expect.anything(), [], expect.objectContaining({ updateAccountNames: true }) ) }) it('passes update_account_names: false through to executeSIEImport', async () => { vi.mocked(parseSIEFile).mockReturnValueOnce({} as never) vi.mocked(executeSIEImport).mockResolvedValueOnce({ success: true, importId: 'imp-2', fiscalPeriodId: 'fp-1', openingBalanceEntryId: null, journalEntriesCreated: 1, journalEntryIds: ['je-1'], errors: [], warnings: [], }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's update const op = makePendingOp({ operation_type: 'import_sie', params: { file_content: '#FLAGGA 0\n', filename: 'test.sie', mappings: [], create_fiscal_period: true, import_opening_balances: true, import_transactions: true, update_account_names: false, }, }) await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(executeSIEImport).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', expect.anything(), [], expect.objectContaining({ updateAccountNames: false }) ) }) it('rejects when required params are missing', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'import_sie', params: { filename: 'x.sie' } }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(parseSIEFile).not.toHaveBeenCalled() }) it('returns the executeSIEImport errors when success=false', async () => { vi.mocked(parseSIEFile).mockReturnValueOnce({} as never) vi.mocked(executeSIEImport).mockResolvedValueOnce({ success: false, importId: null, fiscalPeriodId: null, openingBalanceEntryId: null, journalEntriesCreated: 0, journalEntryIds: [], errors: ['duplicate import'], warnings: [], }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'import_sie', params: { file_content: '#FLAGGA 0\n', filename: 'test.sie', mappings: [], create_fiscal_period: true, import_opening_balances: false, import_transactions: true, }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.error).toMatch(/duplicate import/) }) }) // ─── credit_invoice ───────────────────────────────────────────────── describe('commitPendingOperation: credit_invoice', () => { it('happy path (accrual): inserts negated credit note and books JE', async () => { const original = makeInvoice({ id: 'inv-1', invoice_number: 'F-2024001', status: 'sent', document_type: 'invoice', subtotal: 1000, vat_amount: 250, total: 1250, }) const originalWithItems = { ...original, items: [ { sort_order: 0, description: 'Service', quantity: 1, unit: 'st', unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }, ], } const creditNoteRow = { ...original, id: 'cn-1', invoice_number: 'KR-F-2024001' } const completeCreditNote = { ...creditNoteRow, customer: { name: 'Acme AB' }, items: [] } const { supabase, enqueue } = createQueuedMockSupabase() // 0: CAS claim enqueue({ data: { id: 'op-1' }, error: null }) // 1: fetch original with items enqueue({ data: originalWithItems, error: null }) // 2: insert credit note enqueue({ data: creditNoteRow, error: null }) // 3: insert items (await thenable) enqueue({ data: null, error: null }) // 4: update original status='credited' enqueue({ data: null, error: null }) // 5: re-fetch complete credit note with customer + items enqueue({ data: completeCreditNote, error: null }) // 6: company_settings enqueue({ data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null }) // 7: update invoice with journal_entry_id enqueue({ data: null, error: null }) // 8: dispatcher's pending_operations update enqueue({ data: null, error: null }) vi.mocked(createCreditNoteJournalEntry).mockResolvedValueOnce({ id: 'je-1' } as never) const op = makePendingOp({ operation_type: 'credit_invoice', params: { invoice_id: 'inv-1', reason: 'Wrong amount' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ credit_note_id: 'cn-1', journal_entry_id: 'je-1' }) expect(createCreditNoteJournalEntry).toHaveBeenCalled() }) it('skips JE on cash accounting', async () => { const original = makeInvoice({ id: 'inv-1', status: 'paid', document_type: 'invoice' }) const originalWithItems = { ...original, items: [] } const creditNoteRow = { ...original, id: 'cn-2', invoice_number: 'KR-F-2024001' } const completeCreditNote = { ...creditNoteRow, customer: null, items: [] } const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: originalWithItems, error: null }) enqueue({ data: creditNoteRow, error: null }) enqueue({ data: null, error: null }) enqueue({ data: null, error: null }) enqueue({ data: completeCreditNote, error: null }) enqueue({ data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null }) // no JE update; go straight to dispatcher update enqueue({ data: null, error: null }) const op = makePendingOp({ operation_type: 'credit_invoice', params: { invoice_id: 'inv-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ credit_note_id: 'cn-2', journal_entry_id: null }) expect(createCreditNoteJournalEntry).not.toHaveBeenCalled() }) it('auto-rejects when invoice is already credited (409)', async () => { const original = makeInvoice({ id: 'inv-1', status: 'credited', document_type: 'invoice' }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { ...original, items: [] }, error: null }) // dispatcher auto-reject path also does an update enqueue({ data: null, error: null }) const op = makePendingOp({ operation_type: 'credit_invoice', params: { invoice_id: 'inv-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('rejected') expect(result.auto_rejected).toBe(true) expect(result.http_status).toBe(409) }) it('rejects when invoice_id is missing', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'credit_invoice', params: {} }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) }) it('rejects invoices with status outside sent/paid/overdue', async () => { const original = makeInvoice({ id: 'inv-1', status: 'draft', document_type: 'invoice' }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { ...original, items: [] }, error: null }) enqueue({ data: null, error: null }) // dispatcher's reject update const op = makePendingOp({ operation_type: 'credit_invoice', params: { invoice_id: 'inv-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) }) }) // ─── credit_supplier_invoice ──────────────────────────────────────── describe('commitPendingOperation: credit_supplier_invoice', () => { it('normalizes copied storage and reverses with the untouched original items', async () => { const originalItems = [ { sort_order: 0, description: 'Office supplies', quantity: 1, unit: 'st', unit_price: 1000, line_total: 1000, account_number: '5410', vat_code: null, vat_rate: 25, vat_amount: 250, dimensions: {}, }, ] const original = { ...makeSupplierInvoice({ id: 'supplier-invoice-1', status: 'registered' }), supplier: { name: 'Office Depot AB', supplier_type: 'swedish_business' }, items: originalItems, } const creditNote = makeSupplierInvoice({ id: 'supplier-credit-1', is_credit_note: true, credited_invoice_id: original.id, }) const { supabase, enqueueMany, findCall } = createQueuedMockSupabase() enqueueMany([ { data: { id: 'op-1' }, error: null }, { data: original, error: null }, { data: 2, error: null }, { data: creditNote, error: null }, { data: null, error: null }, { data: { accounting_method: 'accrual' }, error: null }, { data: null, error: null }, { data: null, error: null }, { data: null, error: null }, ]) vi.mocked(createSupplierCreditNoteEntry).mockResolvedValueOnce({ id: 'je-1' } as never) const op = makePendingOp({ operation_type: 'credit_supplier_invoice', params: { supplier_invoice_id: original.id }, }) const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', op, ) expect(result.status).toBe('committed') const insertArgs = findCall('supplier_invoice_items', 'insert') const insertedItems = insertArgs?.[0] as Array<{ vat_rate: number }> expect(insertedItems[0]?.vat_rate).toBe(0.25) expect(createSupplierCreditNoteEntry).toHaveBeenCalledWith( supabase, 'company-1', 'user-1', creditNote, originalItems, 'swedish_business', 'Office Depot AB', ) }) }) // ─── attach_document_to_transaction ───────────────────────────────── describe('commitPendingOperation: attach_document_to_transaction', () => { const baseOp: Partial = { operation_type: 'attach_document_to_transaction', params: { transaction_id: 'tx-1', document_id: 'doc-1' }, } it('auto-rejects 404 when transaction is not in the company', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // tx fetch: not found enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(404) }) it('auto-rejects 409 when existing pinned doc is räkenskapsinformation', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: 'doc-old', journal_entry_id: null }, error: null }) enqueue({ data: { journal_entry_id: 'je-99' }, error: null }) // existing doc fetch: locked enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) it('translates BFL_DOCUMENT_IMMUTABILITY trigger error into auto-reject 409', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch enqueue({ data: null, error: { code: 'P0001', message: 'BFL_DOCUMENT_IMMUTABILITY: cannot detach or swap document …', }, }) enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) it('happy path uncategorized: attaches without propagation', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') }) it('propagates to journal entry when tx was categorized between staging and commit', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // UPDATE returning post-state enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link enqueue({ data: null, error: null }) // doc propagation update enqueue({ data: { document_id: null }, error: null }) // completion: tx pin lookup enqueue({ data: [], error: null }) // completion: matched inbox items (none) enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') }) it('completes matched inbox items when the tx is anchored via a bulk-book samlingsverifikat', async () => { // A bulk-booked tx keeps transactions.journal_entry_id null: the verifikat // hangs off transaction_voucher_links. Attaching a hunted receipt to it // must still resolve the matched inbox item, or it strands as "linked" // forever (the 2026-08-12 report). const { supabase, enqueue, findCalls } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning (still null) enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links enqueue({ data: { document_id: null }, error: null }) // propagation: tx pin lookup enqueue({ data: [{ id: 'inbox-9', document_id: null }], error: null }) // matched inbox items enqueue({ data: null, error: null }) // created_journal_entry_id stamp enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([ { created_journal_entry_id: 'je-9' }, ]) }) it('still commits when the inbox-link best-effort update errors', async () => { // Inbox sync is best-effort: a failure to mark the inbox row as matched // must not roll back the (compliant) doc→tx attach. Mirrors the REST // route's swallow-and-log behaviour. The Supabase client resolves with // { error } rather than rejecting, so we both confirm the op commits AND // that the error was actually inspected and logged (not silently dropped). const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // tx UPDATE returning enqueue({ data: null, error: { message: 'inbox row missing or RLS-blocked' } }) // inbox link: errors enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either enqueue({ data: null, error: null }) // dispatcher commit update const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') expect(spy).toHaveBeenCalledWith( '[commitAttach] Failed to link inbox item:', expect.objectContaining({ message: 'inbox row missing or RLS-blocked' }), ) spy.mockRestore() }) it('touches the invoice_inbox_items table to sync matched_transaction_id', async () => { // Argument-level check that the executor actually reaches into // invoice_inbox_items to keep the inbox UI in sync with the staged-write // path. Otherwise the inbox row stays in "Behöver åtgärd" forever. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // tx UPDATE returning enqueue({ data: null, error: null }) // invoice_inbox_items link enqueue({ data: null, error: null }) // dispatcher commit update await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) const tablesTouched = (supabase.from as ReturnType).mock.calls.map(c => c[0]) expect(tablesTouched).toContain('invoice_inbox_items') }) it('auto-rejects 409 when the document already belongs to a different verifikation', async () => { // Without this guard the attach would pin a consumed doc (undetachable // per the transactions immutability trigger) and the later propagation // would corrupt or be blocked by the doc-metadata immutability trigger. // Mirrors the REST route's guard. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: 'je-1' }, error: null }) enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) it('skips the propagation write on an idempotent re-attach (doc already on the same verifikation)', async () => { // The period-lock trigger raises on ANY journal_entry_id write: even a // same-value rewrite: so an unconditional re-run would fail an otherwise // idempotent re-attach once the period locks. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch: same JE enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // tx UPDATE returning enqueue({ data: null, error: null }) // invoice_inbox_items link enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') // document_attachments touched once (the doc fetch): no propagation write. const tablesTouched = (supabase.from as ReturnType).mock.calls.map(c => c[0]) expect(tablesTouched.filter((t) => t === 'document_attachments')).toHaveLength(1) }) it('maps a period-lock propagation failure to auto-reject 409', async () => { // A retry could never succeed until the period is unlocked, so the // generic 500 "försök igen" would be a false promise. Mirrors the REST // route's mapping. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null }) enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // tx UPDATE returning: booked meanwhile enqueue({ data: null, error: null }) // invoice_inbox_items link enqueue({ data: null, error: { message: 'cannot link document in a locked/closed fiscal period' }, }) // propagation blocked by enforce_period_lock enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) }) // ─── link_document_to_voucher ───────────────────────────────── describe('commitPendingOperation: link_document_to_voucher', () => { const baseOp: Partial = { operation_type: 'link_document_to_voucher', params: { document_id: 'doc-1', journal_entry_id: 'je-1' }, } it('auto-rejects 404 when document is not in the company', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // doc fetch: not found enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(404) }) it('auto-rejects 409 when doc is already linked to a different posted JE (WORM guard)', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) it('allows re-linking when existing linked JE is not yet posted (draft)', async () => { // A doc linked to a draft (uncommitted) JE can be moved: only posted // verifikationer trigger the WORM guard. const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-DRAFT' }, error: null }) // doc fetch enqueue({ data: { status: 'draft' }, error: null }) // WORM: existing JE: not posted enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: { id: 'doc-1', file_name: 'kvitto.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null }, error: null, }) // linkToJournalEntry: doc update enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') }) it('happy path: links doc to verifikation with no prior journal_entry_id', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: { id: 'doc-1', file_name: 'faktura.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null }, error: null, }) // linkToJournalEntry: doc update enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ document_id: 'doc-1', journal_entry_id: 'je-1', }) }) it('auto-rejects 409 when linkToJournalEntry throws a period-lock error', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: null, error: { message: 'cannot link document in a locked/closed fiscal period' }, }) // linkToJournalEntry: doc update: period locked enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) }) // ─── link_documents_to_vouchers (bulk) ───────────────────────────── describe('commitPendingOperation: link_documents_to_vouchers', () => { const baseOp: Partial = { operation_type: 'link_documents_to_vouchers', params: { links: [ { document_id: 'doc-1', journal_entry_id: 'je-1', journal_entry_line_id: null }, { document_id: 'doc-2', journal_entry_id: 'je-2', journal_entry_line_id: null }, ], }, } it('happy path: links every row and reports zero skipped', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim // row 1: doc-1 -> je-1 enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: { id: 'doc-1', file_name: 'kvitto1.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null }, error: null, }) // linkToJournalEntry: doc update // row 2: doc-2 -> je-2 enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null }, error: null, }) // linkToJournalEntry: doc update enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ linked_count: 2, skipped_count: 0, }) }) it('mixed batch: a missing document is skipped without blocking the other rows', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim // row 1: doc-1 not found in this company enqueue({ data: null, error: null }) // doc fetch: not found // row 2: doc-2 -> je-2 succeeds enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null }, error: null, }) // linkToJournalEntry: doc update enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) // Non-transactional bulk op: overall status is 'committed' even with // partial skips (mirrors bulk_book_inbox_items's booked/skipped split), // since no irreversible side-effect was posted for the skipped row. expect(result.status).toBe('committed') expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 }) expect((result.data as { skipped: Array<{ document_id: string; reason: string }> }).skipped[0]).toMatchObject({ document_id: 'doc-1', reason: 'Bilagan hittades inte.', }) }) it('WORM guard skips a row whose doc is already linked to a different posted JE', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim // row 1: doc-1 already linked to je-OTHER (posted) -> skipped enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status // row 2: doc-2 -> je-2 succeeds enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership enqueue({ data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null }, error: null, }) // linkToJournalEntry: doc update enqueue({ data: null, error: null }) // dispatcher commit update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 }) }) it('auto-rejects 409 when every row is skipped instead of recording a committed no-op', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim // row 1: doc-1 already linked to a posted JE -> WORM skip enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status // row 2: doc-2 not found -> skip enqueue({ data: null, error: null }) // doc fetch: not found enqueue({ data: null, error: null }) // dispatcher reject update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp(baseOp), ) // Partial skips stay 'committed' (see the mixed-batch case), but a batch // that linked nothing must not leave an audit record asserting a run that // changed nothing: the single-document executor returns 409 for the same // conditions, and the bulk path must not be the weaker one. expect(result.status).toBe('rejected') expect(result.http_status).toBe(409) }) it('fails 400 when the staged params carry an empty links array', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher failure update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp({ ...baseOp, params: { links: [] } }), ) // 400 is not auto-reject territory (only 404 and 409 are), so the operation // is recorded as failed rather than rejected. expect(result.status).toBe('failed') expect(result.http_status).toBe(400) }) it('fails 400 when links is missing entirely', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher failure update const result = await commitPendingOperation( supabase as never, 'user-1', 'company-1', makePendingOp({ ...baseOp, params: {} }), ) expect(result.status).toBe('failed') expect(result.http_status).toBe(400) }) }) // ─── categorize_transaction: dimensions propagation (PR7) ────────── describe('commitPendingOperation: categorize_transaction: dimensions propagation (PR7)', () => { it('threads the staged dimensions bag into categorizeMatchedTransaction opts', async () => { vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({ data: { journal_entry_id: 'je-1' }, }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's commit update const op = makePendingOp({ operation_type: 'categorize_transaction', params: { transaction_id: 'tx-1', category: 'office_supplies', dimensions: { '1': 'KS01', '6': 'P001' }, }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(categorizeMatchedTransaction).toHaveBeenCalledTimes(1) const call = vi.mocked(categorizeMatchedTransaction).mock.calls[0] expect(call[1]).toBe('user-1') expect(call[2]).toBe('company-1') expect(call[3]).toBe('tx-1') expect(call[4].dimensions).toEqual({ '1': 'KS01', '6': 'P001' }) }) it('coerces an INVALID staged bag to undefined (drift/tamper gate)', async () => { vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({ data: { journal_entry_id: 'je-1' }, }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's commit update const op = makePendingOp({ operation_type: 'categorize_transaction', params: { transaction_id: 'tx-1', category: 'office_supplies', // '0' is not a valid SIE dimension number: the whole bag is rejected // and booking proceeds without dimensions. dimensions: { '0': 'X' }, }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4] expect(opts.dimensions).toBeUndefined() }) it('passes no dimensions when nothing was staged', async () => { vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({ data: { journal_entry_id: 'je-1' }, }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's commit update const op = makePendingOp({ operation_type: 'categorize_transaction', params: { transaction_id: 'tx-1', category: 'office_supplies' }, }) await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4] expect(opts.dimensions).toBeUndefined() }) }) // ─── categorize_transaction: account_override tamper gate ─────────────────── describe('commitPendingOperation: categorize_transaction account_override', () => { it('rejects loudly when a stored override is present but malformed (tamper/drift)', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's rejected update const op = makePendingOp({ operation_type: 'categorize_transaction', params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '40a0' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) // Never degrade to the category default: the approver approved a preview // showing the override account. 400 lands as 'failed' (the dispatcher // reserves auto-reject for 404/409); the point is that the core is never // called and the error names the override. expect(result.status).toBe('failed') expect(result.http_status).toBe(400) expect(result.error).toContain('account_override') expect(categorizeMatchedTransaction).not.toHaveBeenCalled() }) it('threads a valid stored override into the core opts', async () => { vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({ data: { journal_entry_id: 'je-1' }, }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's commit update const op = makePendingOp({ operation_type: 'categorize_transaction', params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '4020' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4] expect(opts.accountOverride).toBe('4020') }) it('passes undefined when the staged params carry account_override: null (no override)', async () => { vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({ data: { journal_entry_id: 'je-1' }, }) const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: null, error: null }) // dispatcher's commit update const op = makePendingOp({ operation_type: 'categorize_transaction', params: { transaction_id: 'tx-1', category: 'expense_other', account_override: null }, }) await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4] expect(opts.accountOverride).toBeUndefined() }) }) describe('commitPendingOperation: mark_invoice_sent honours defer_invoice_booking (#967)', () => { it('marks the invoice sent WITHOUT booking when the company defers invoice booking', async () => { const invoiceEntries = await import('@/lib/bookkeeping/invoice-entries') const bookSpy = vi.spyOn(invoiceEntries, 'createInvoiceJournalEntry') const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim enqueue({ data: makeInvoice({ id: 'invoice-1', status: 'draft', invoice_number: 'F-2026001', credited_invoice_id: null, }), error: null, }) enqueue({ data: { accounting_method: 'accrual', defer_invoice_booking: true, entity_type: 'enskild_firma', bankgiro: '123-4567', }, error: null, }) enqueue({ data: null, error: null }) // status update enqueue({ data: null, error: null }) // dispatcher update const op = makePendingOp({ operation_type: 'mark_invoice_sent', params: { invoice_id: 'invoice-1' }, }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') expect(result.data).toMatchObject({ status: 'sent', journal_entry_id: null }) // Same gate as the dashboard: deferred companies book via the explicit // Bokför step, never at mark-sent. expect(bookSpy).not.toHaveBeenCalled() bookSpy.mockRestore() }) })