Files
accounted/lib/pending-operations/__tests__/executors.test.ts
T
MattssonandClaude Opus 4.7 5725c25bf1 Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate

New MCP tool gnubok_create_transactions stages 1–10 transactions per call
as pending_operations of type create_transaction (risk: medium). Each item
becomes its own card on /pending; on confirm, the executor inserts the row
into transactions with import_source='mcp' so MCP-staged ingestion is
distinguishable from PSD2 sync. Designed for skill workflows that pull
external data (e.g., Airtable) and want the user to gate the writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bas): strip concatenated group headers from corrupted account names

A chart-data import bug had glued the next group's header onto the last
account in each preceding group across all eight bas-data class files
(e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27
PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names
surface in transaction dropdowns, ledgers, SIE exports and årsredovisning,
and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet
(6999) accounts specifically.

- Cleans 69 account_name and 64 description fields across class-1..8 files
- Adds a regression test asserting no name contains a concatenated header
- Ships an idempotent safety-net migration that updates already-seeded
  chart_of_accounts rows, gated on the corrupted string so user
  customizations are preserved

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(errors): add structured error codes and handling for various operations

- Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application.
- Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors.
- Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints.
- Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations.
- Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping.
- Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry.

* Refactor supplier API routes to use context-based logging and error handling

- Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`.
- Enhanced error handling to provide structured error responses for supplier creation and listing.
- Updated logging to include request IDs for better traceability.
- Introduced new error codes for supplier-related operations.
- Refactored tax deadlines cron job to utilize context and improved error handling.
- Updated ESLint configuration to enforce logging practices across API and lib directories.
- Enhanced arcim migration extension with structured error handling and logging.
- Added classification for provider errors to improve user-facing error messages.
- Introduced request ID in extension context for better log correlation.

* fix(route-context): update DynamicParams type for improved type safety in route handlers

* feat(transactions): add 'create_transaction' operation to PendingOperationType

* fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function

* fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:12:02 +02:00

525 lines
20 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, makeInvoice, makeFiscalPeriod } 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(),
}
})
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/bookkeeping/invoice-entries', async () => {
const actual =
await vi.importActual<typeof import('@/lib/bookkeeping/invoice-entries')>(
'@/lib/bookkeeping/invoice-entries'
)
return {
...actual,
createCreditNoteJournalEntry: 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 { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
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_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/)
})
})
// ─── 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')
})
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)
})
})
// ─── 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 }) // 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 }) // doc propagation 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')
})
})