Files
accounted/lib/pending-operations/__tests__/executors.test.ts
T
01d903d3a3 fix(mcp): link_document_to_voucher marks the inbox item handled; document lists stop misleading agents (#2170)
Three MCP feedback reports (seq 265062, 288474, 288577; three companies) hit
the same hole: link_document_to_voucher attaches the document to the
verifikat but never stamps the inbox item it came from, and both inbox read
surfaces derive "handled" from the inbox row's own link columns, never from
document_attachments.journal_entry_id. So an attached document stayed
"unprocessed" forever: agents re-saw it as missing underlag (one reporter
paged 750 rows to find the ~15 real ones), and one user read the five
leftover rows as duplicates and was about to delete the only copies of
underlag sitting on posted verifikat.

- commitLinkDocumentToVoucher and the bulk twin now stamp
  invoice_inbox_items.created_journal_entry_id, keyed on document_id, CAS on
  both link columns, 23505 tolerated (samlingsverifikat). Same shape as the
  create_voucher + inbox_item_id stamp; best-effort so inbox bookkeeping
  never rolls back a committed link.
- gnubok_list_unmatched_documents returns file_name (same embed
  list_inbox_items uses) and the extraction's page coverage, so an agent can
  tell "no total on this document" from "we read 3 of 38 pages" and does not
  have to fetch each document to learn what it is (seq 265062, 288574).
- gnubok_list_transactions_without_documents no longer echoes the column
  default "uncategorized" on rows that are booked by construction:
  list_uncategorized_transactions uses the same word for "no journal entry
  yet", and an agent read the label and tried to re-book an already-booked
  share-capital deposit (seq 288574).

No tools/list payload change: the unmatched-documents item schema is
untyped and the category field already allowed null.


Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 08:57:53 +02:00

1882 lines
75 KiB
TypeScript

/**
* 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<typeof import('@/lib/core/bookkeeping/period-service')>(
'@/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<typeof import('@/lib/currency/riksbanken')>(
'@/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<typeof import('@/lib/bookkeeping/invoice-entries')>(
'@/lib/bookkeeping/invoice-entries'
)
return {
...actual,
createCreditNoteJournalEntry: vi.fn(),
}
})
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
const actual =
await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
'@/lib/bookkeeping/supplier-invoice-entries'
)
return {
...actual,
createSupplierCreditNoteEntry: vi.fn(),
}
})
vi.mock('@/lib/transactions/categorize-core', async () => {
const actual =
await vi.importActual<typeof import('@/lib/transactions/categorize-core')>(
'@/lib/transactions/categorize-core'
)
return {
...actual,
categorizeMatchedTransaction: vi.fn(),
}
})
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
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>): 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<PendingOperation> = {
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<typeof vi.fn>).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<typeof vi.fn>).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<PendingOperation> = {
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 }) // inbox stamp (best-effort)
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, calls, findCall } = 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 }) // inbox stamp (best-effort)
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',
})
// The inbox item the document came from is stamped as handled, keyed on
// document_id and CAS-guarded on both link columns: otherwise
// list_inbox_items / list_unmatched_documents keep listing an attached
// document as unprocessed forever.
expect(findCall('invoice_inbox_items', 'update')).toEqual([{ created_journal_entry_id: 'je-1' }])
const inboxFilters = calls
.filter((c) => c.table === 'invoice_inbox_items' && (c.method === 'eq' || c.method === 'is'))
.map((c) => c.args)
expect(inboxFilters).toEqual([
['document_id', 'doc-1'],
['company_id', 'company-1'],
['created_journal_entry_id', null],
['created_supplier_invoice_id', null],
])
})
it('inbox stamp is best-effort: a failed stamp never fails the committed link', 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: { code: '23505', message: 'duplicate key value' } }) // inbox stamp: samlingsverifikat already claimed
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<PendingOperation> = {
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
enqueue({ data: null, error: null }) // inbox stamp (best-effort)
// 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 }) // inbox stamp (best-effort)
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 }) // inbox stamp (best-effort)
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 }) // inbox stamp (best-effort)
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()
})
})