Files
accounted/lib/pending-operations/__tests__/executors.test.ts
T
Mattsson fbe4e18730 feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)
* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link

gnubok_categorize_transaction only spoke a 19-category enum mapping to 21
hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were
unreachable from the agent surface even when active in the chart.

- add account_override to gnubok_categorize_transaction with v1 REST
  semantics via a shared helper (lib/bookkeeping/account-override.ts):
  business-side replacement, class-2 auto-VAT drop with the 2610-2649
  moms-line exception, plus a same-account degenerate guard; validated at
  staging and re-validated at commit
- align the gnubok_create_voucher staging gate with the engine's seeding
  semantics: BAS 2026 accounts merely absent from the chart pass (the
  engine backfills them at commit) and the preview lists
  will_activate_accounts with BAS-name fallback; non-BAS unknown and
  inactive accounts still rejected
- stop suggest_categories silently dropping mapping rules whose account
  is outside the fixed category maps; they surface with the rule's own
  account and an explanatory match_reason
- correct the create_account next-step hint (categorize could never use
  the new account before; now true via account_override)
- point the settings "Kontoplan (BAS)" link at /chart-of-accounts and
  redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in
  #850; the deep link never worked after the #854 merge collision)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): address review findings on account_override

- commit executor rejects a present-but-malformed stored account_override
  loudly instead of degrading to the category default (CodeRabbit major;
  the approver approved a preview showing the override account); with
  commitPendingOperation regression tests
- accountToCategory returns null for unknown income accounts so custom
  income accounts get the same diagnostic as expenses (CodeRabbit minor),
  with income + reason-accumulation tests (CodeRabbit nit)
- pin the class-2 VAT-drop balance invariant with a test through
  buildTransactionEntryLines (Swedish compliance review: gross booking,
  never an unbalanced net + missing VAT leg)
- account_override description asks the agent to state the actual
  affärshändelse in notes when overriding (BFL 5 kap description concern)
- eventBus.clear() in the two new test suites (CodeRabbit minor)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent

Round-2 Swedish compliance finding: the class-2 VAT drop did not cover
margin-scheme (VMB) accounts in class 3/4, which are the override's
flagship use case, so a forgotten vat_treatment attached the category
default standard_25 and booked an ingående-moms deduction on a
transaction where input VAT is not deductible (ML 2023:200).

applyAccountOverride now takes explicit VAT intent (vat_treatment or
vat_amount present) and books GROSS with no auto-VAT line without it:
forgetting the flag under-deducts (lawful), never over-deducts. Both
call sites (MCP staging preview, commit core) derive the flag the same
way; the tool description states the enforced behavior. Deliberate
divergence from v1 REST recorded in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: move stray decision-log entry to the root DECISIONS.md

The round-2 entry was appended from the wrong working directory and
landed as lib/bookkeeping/__tests__/DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 01:24:48 +02:00

1434 lines
55 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'
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/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/)
})
})
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: 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 }) // 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)
})
})
// ─── 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()
})
})