diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts new file mode 100644 index 00000000..6e517e86 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -0,0 +1,279 @@ +/** + * Staging-time gate tests for gnubok_create_voucher. + * + * The executor-level gates (period lock, balance, status === 'posted' for + * correct_entry) are tested in lib/pending-operations/__tests__/. This file + * covers the pre-staging gates added to the MCP tool layer for UX — explicit + * fiscal_period_id validation, inactive/missing account rejection, and the + * source_type-not-staged invariant. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' + +vi.mock('@/lib/bookkeeping/engine', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/engine' + ) + return { + ...actual, + findFiscalPeriod: vi.fn(), + } +}) + +import { tools } from '../server' +import { findFiscalPeriod } from '@/lib/bookkeeping/engine' + +const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')! +const correctEntry = tools.find((t) => t.name === 'gnubok_correct_entry')! + +beforeEach(() => { + vi.clearAllMocks() +}) + +const balancedLines = [ + { account_number: '1010', debit_amount: 250, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 250 }, +] + +describe('gnubok_create_voucher — staging gates', () => { + it('is registered and mapped to bookkeeping:write scope', async () => { + const { TOOL_SCOPE_MAP } = await import('@/lib/auth/api-keys') + expect(createVoucher).toBeDefined() + expect(createVoucher.annotations.readOnlyHint).toBe(false) + expect(TOOL_SCOPE_MAP.gnubok_create_voucher).toBe('bookkeeping:write') + }) + + it('rejects unbalanced lines before staging', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'unbalanced', + fiscal_period_id: 'fp-1', + lines: [ + { account_number: '1010', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 80 }, + ], + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/not balanced/i) + }) + + it('rejects when an explicit fiscal_period_id is closed', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // fiscal_periods fetch returns a closed period + enqueue({ + data: { + id: 'fp-closed', + is_closed: true, + period_start: '2026-01-01', + period_end: '2026-03-31', + name: 'Q1 2026', + }, + error: null, + }) + + await expect( + createVoucher.execute( + { + entry_date: '2026-02-15', + description: 'attempt to post in closed Q1', + fiscal_period_id: 'fp-closed', + lines: balancedLines, + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/låst/i) + // findFiscalPeriod must NOT be called when an explicit ID was supplied. + expect(findFiscalPeriod).not.toHaveBeenCalled() + }) + + it('rejects when an explicit fiscal_period_id does not exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null }) // fiscal_periods fetch — not found + + await expect( + createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'unknown period uuid', + fiscal_period_id: 'fp-nonexistent', + lines: balancedLines, + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/not found/i) + }) + + it('rejects when entry_date is outside the supplied period', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'fp-1', + is_closed: false, + period_start: '2026-01-01', + period_end: '2026-03-31', + name: 'Q1 2026', + }, + error: null, + }) + + await expect( + createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'date outside Q1', + fiscal_period_id: 'fp-1', + lines: balancedLines, + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/utanför/i) + }) + + it('rejects when a referenced account is missing from the chart', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'fp-1', + is_closed: false, + period_start: '2026-01-01', + period_end: '2026-12-31', + name: '2026', + }, + error: null, + }) + // chart_of_accounts returns nothing — both accounts unknown + enqueue({ data: [], error: null }) + + await expect( + createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'unknown accounts', + fiscal_period_id: 'fp-1', + lines: balancedLines, + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/saknas i kontoplanen/i) + }) + + it('rejects when a referenced account exists but is inactive', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'fp-1', + is_closed: false, + period_start: '2026-01-01', + period_end: '2026-12-31', + name: '2026', + }, + error: null, + }) + enqueue({ + data: [ + { account_number: '1010', account_name: 'Balanserade utgifter', is_active: false }, + { account_number: '1930', account_name: 'Företagskonto', is_active: true }, + ], + error: null, + }) + + await expect( + createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'inactive account', + fiscal_period_id: 'fp-1', + lines: balancedLines, + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/inaktiv/i) + }) + + it('happy path: stages with no source_type in params (executor hardcodes it)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'fp-1', + is_closed: false, + period_start: '2026-01-01', + period_end: '2026-12-31', + name: '2026', + }, + error: null, + }) + enqueue({ + data: [ + { account_number: '1010', account_name: 'Balanserade utgifter', is_active: true }, + { account_number: '1930', account_name: 'Företagskonto', is_active: true }, + ], + error: null, + }) + enqueue({ data: { id: 'op-staged' }, error: null }) // pending_operations insert + + const result = (await createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'Capitalize Cursor', + fiscal_period_id: 'fp-1', + lines: balancedLines, + }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; operation_id?: string; preview: Record } + + expect(result.staged).toBe(true) + expect(result.operation_id).toBe('op-staged') + expect(result.preview.total_debit).toBe(250) + expect(result.preview.total_credit).toBe(250) + + // Critical: the staged pending_operations row must NOT carry source_type. + // The executor always hardcodes 'manual'. Look at the insert call. + const insertCalls = (supabase.from as ReturnType).mock.calls + expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true) + }) +}) + +describe('gnubok_correct_entry — registration', () => { + it('is registered with bookkeeping:write scope and is not read-only', async () => { + const { TOOL_SCOPE_MAP } = await import('@/lib/auth/api-keys') + expect(correctEntry).toBeDefined() + expect(correctEntry.annotations.readOnlyHint).toBe(false) + expect(TOOL_SCOPE_MAP.gnubok_correct_entry).toBe('bookkeeping:write') + }) + + it('rejects unbalanced replacement lines before staging', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + correctEntry.execute( + { + entry_id: 'je-1', + lines: [ + { account_number: '2645', debit_amount: 250, credit_amount: 0 }, + { account_number: '2614', debit_amount: 0, credit_amount: 200 }, + ], + }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/not balanced/i) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 015ebe28..484f9473 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -42,7 +42,7 @@ import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' -import { reverseEntry } from '@/lib/bookkeeping/engine' +import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { closePeriod, lockPeriod } from '@/lib/core/bookkeeping/period-service' import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' import { generateSIEExport } from '@/lib/reports/sie-export' @@ -5367,6 +5367,334 @@ export const tools: McpTool[] = [ ) }, }, + + // ── Phase 4: arbitrary-line bookkeeping primitives ─────────────── + + { + name: 'gnubok_create_voucher', + description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (e.g. 1010), period-end accruals, FX adjustments, and rättelseposter outside categorize_transaction. HIGH risk — always staged, never auto-committed.', + inputSchema: { + type: 'object', + properties: { + entry_date: { type: 'string', description: 'Voucher date (YYYY-MM-DD)' }, + description: { type: 'string', description: 'Verifikationstext (required, min 1 char)' }, + fiscal_period_id: { type: 'string', description: 'UUID of fiscal period. If omitted, resolved from entry_date.' }, + voucher_series: { type: 'string', description: 'Single letter A–Z. Defaults to A.' }, + notes: { type: 'string', description: 'Internal notes (max 2000 chars) — visible on the verifikation but not on reports.' }, + lines: { + type: 'array', + description: 'At least 2 balanced lines. sum(debit_amount) === sum(credit_amount), both > 0.', + items: { + type: 'object', + properties: { + account_number: { type: 'string', description: '4-digit BAS account number, e.g. "1010"' }, + debit_amount: { type: 'number', description: 'Debit amount in SEK (≥ 0)' }, + credit_amount: { type: 'number', description: 'Credit amount in SEK (≥ 0)' }, + line_description: { type: 'string' }, + currency: { type: 'string', description: 'ISO 4217, defaults to SEK' }, + amount_in_currency: { type: 'number', description: 'Original amount if currency is not SEK' }, + exchange_rate: { type: 'number' }, + tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta mapping. The BAS account number is what determines which ruta the line lands in (e.g. 2641 → ruta 48, 2614 → ruta 30). Pick the correct account first.' }, + cost_center: { type: 'string' }, + project: { type: 'string' }, + }, + required: ['account_number'], + }, + }, + }, + required: ['entry_date', 'description', 'lines'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const entryDate = args.entry_date as string + const description = args.description as string + const rawLines = args.lines as Array> | undefined + + if (!entryDate || !description || !Array.isArray(rawLines) || rawLines.length < 2) { + throw new Error('entry_date, description, and at least two lines are required') + } + + // Normalize so validateBalance + preview see consistent numeric types. + const lines = rawLines.map((l) => ({ + account_number: String(l.account_number ?? ''), + debit_amount: Number(l.debit_amount) || 0, + credit_amount: Number(l.credit_amount) || 0, + line_description: l.line_description ? String(l.line_description) : undefined, + currency: l.currency ? String(l.currency) : undefined, + amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined, + exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined, + tax_code: l.tax_code ? String(l.tax_code) : undefined, + cost_center: l.cost_center ? String(l.cost_center) : undefined, + project: l.project ? String(l.project) : undefined, + })) + + // Pre-flight: catch unbalanced lines before staging so the agent gets a + // tight feedback loop instead of a rejected pending_operation later. + const balance = validateBalance(lines) + if (!balance.valid) { + throw new Error( + `Lines are not balanced: debits ${balance.totalDebit} SEK, credits ${balance.totalCredit} SEK. ` + + 'Both must be positive and equal.' + ) + } + + // Resolve fiscal period. Two paths: + // 1. Caller supplied fiscal_period_id → verify it exists and is open. + // 2. Omitted → look up the open period covering entry_date. + // Both paths converge on a Swedish-language error if no valid open + // period is available. (NOTE: the executor re-checks period_lock at + // commit time — this staging gate is advisory and exists for UX, the + // commit-time guard is the authoritative one. Don't remove it as + // "redundant".) + let fiscalPeriodId = (args.fiscal_period_id as string | undefined) ?? null + if (fiscalPeriodId) { + const { data: period, error: periodErr } = await supabase + .from('fiscal_periods') + .select('id, is_closed, period_start, period_end, name') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .maybeSingle() + if (periodErr || !period) { + throw new Error(`Fiscal period ${fiscalPeriodId} not found for this company.`) + } + if (period.is_closed) { + throw new Error( + `Räkenskapsperioden "${period.name ?? fiscalPeriodId}" är låst. ` + + 'Lås upp perioden, eller välj en öppen period.' + ) + } + // Defense in depth: also verify the supplied period actually covers + // entry_date so the engine's EntryDateOutsideFiscalPeriodError surfaces + // as a Swedish message rather than a generic engine error. + if (entryDate < period.period_start || entryDate > period.period_end) { + throw new Error( + `Datumet ${entryDate} ligger utanför "${period.name ?? 'perioden'}" (${period.period_start}–${period.period_end}).` + ) + } + } else { + fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate) + } + if (!fiscalPeriodId) { + throw new Error(`No open fiscal period covers ${entryDate}. Open a period or pick a different date.`) + } + + // Resolve account names for the preview so the approver reads + // "1010 Balanserade utgifter / 2440 Leverantörsskulder" rather than + // bare numbers. Also gate: refuse to stage when any line references an + // unknown or inactive account so the approver isn't shown a voucher + // that would fail at commit time anyway. + const accountNumbers = [...new Set(lines.map((l) => l.account_number))] + const { data: accounts } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name, is_active') + .eq('company_id', companyId) + .in('account_number', accountNumbers) + const accountInfo = new Map() + for (const a of accounts || []) { + accountInfo.set(a.account_number as string, { + name: (a.account_name as string) ?? '', + active: Boolean(a.is_active), + }) + } + const unknownAccounts = accountNumbers.filter((n) => !accountInfo.has(n)) + const inactiveAccounts = accountNumbers.filter( + (n) => accountInfo.has(n) && !accountInfo.get(n)!.active, + ) + if (unknownAccounts.length > 0 || inactiveAccounts.length > 0) { + const parts: string[] = [] + if (unknownAccounts.length > 0) { + parts.push(`saknas i kontoplanen: ${unknownAccounts.join(', ')}`) + } + if (inactiveAccounts.length > 0) { + parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`) + } + throw new Error( + `Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` + + 'Aktivera dem i kontoplanen eller välj andra konton.' + ) + } + + const previewLines = lines.map((l) => ({ + account_number: l.account_number, + account_name: accountInfo.get(l.account_number)?.name ?? null, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + line_description: l.line_description ?? null, + })) + + // NOTE: source_type is intentionally NOT included in the staged params. + // The executor hardcodes 'manual' so a tampered or future direct-staged + // pending_operations row can't misrepresent the entry's origin. + return stagePendingOperation(supabase, companyId, userId, 'create_voucher', + `Manuell verifikation: ${description}`, + { + entry_date: entryDate, + description, + fiscal_period_id: fiscalPeriodId, + voucher_series: (args.voucher_series as string) || undefined, + notes: (args.notes as string) || undefined, + lines, + }, + { + entry_date: entryDate, + description, + fiscal_period_id: fiscalPeriodId, + voucher_series: (args.voucher_series as string) || 'A', + total_debit: balance.totalDebit, + total_credit: balance.totalCredit, + line_count: lines.length, + lines: previewLines, + will: 'create a posted journal entry with a fresh sequential voucher number', + }, + actor + ) + }, + }, + + { + name: 'gnubok_correct_entry', + description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§ — storno + new corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645 while preserving the expense leg. Account drives momsdeklaration ruta, not tax_code. HIGH risk.', + inputSchema: { + type: 'object', + properties: { + entry_id: { type: 'string', description: 'UUID of the posted journal entry to correct' }, + lines: { + type: 'array', + description: 'Replacement lines (≥ 2, balanced). Use the same accounts as the original where unchanged.', + items: { + type: 'object', + properties: { + account_number: { type: 'string' }, + debit_amount: { type: 'number' }, + credit_amount: { type: 'number' }, + line_description: { type: 'string' }, + currency: { type: 'string' }, + amount_in_currency: { type: 'number' }, + exchange_rate: { type: 'number' }, + tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta. Pick the correct BAS account first.' }, + cost_center: { type: 'string' }, + project: { type: 'string' }, + }, + required: ['account_number'], + }, + }, + }, + required: ['entry_id', 'lines'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const entryId = args.entry_id as string + const rawLines = args.lines as Array> | undefined + + if (!entryId || !Array.isArray(rawLines) || rawLines.length < 2) { + throw new Error('entry_id and at least two lines are required') + } + + const lines = rawLines.map((l) => ({ + account_number: String(l.account_number ?? ''), + debit_amount: Number(l.debit_amount) || 0, + credit_amount: Number(l.credit_amount) || 0, + line_description: l.line_description ? String(l.line_description) : undefined, + currency: l.currency ? String(l.currency) : undefined, + amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined, + exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined, + tax_code: l.tax_code ? String(l.tax_code) : undefined, + cost_center: l.cost_center ? String(l.cost_center) : undefined, + project: l.project ? String(l.project) : undefined, + })) + + const balance = validateBalance(lines) + if (!balance.valid) { + throw new Error( + `Correction lines not balanced: debits ${balance.totalDebit}, credits ${balance.totalCredit}. ` + + 'Both must be positive and equal.' + ) + } + + // Pre-flight: the executor checks again, but failing fast here gives the + // agent a clearer error message than waiting until commit-time. + // The Supabase types don't infer through `fiscal_periods!inner(...)`, + // so we type the row shape manually rather than fight the generics. + type OriginalRow = { + id: string + status: string + entry_date: string + description: string + voucher_number: number + voucher_series: string + fiscal_period_id: string + fiscal_periods: { name?: string; is_closed?: boolean } | { name?: string; is_closed?: boolean }[] | null + lines: Array<{ + account_number: string + debit_amount: number | string + credit_amount: number | string + line_description: string | null + }> | null + } + const { data, error: origErr } = await supabase + .from('journal_entries') + .select( + 'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' + + 'fiscal_periods!inner(name, is_closed), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)' + ) + .eq('id', entryId) + .eq('company_id', companyId) + .maybeSingle() + const original = data as OriginalRow | null + + if (origErr || !original) throw new Error('Journal entry not found') + if (original.status !== 'posted') { + throw new Error(`Only posted entries can be corrected. Current status: ${original.status}.`) + } + const periodInfo = Array.isArray(original.fiscal_periods) + ? original.fiscal_periods[0] + : original.fiscal_periods + if (periodInfo?.is_closed) { + throw new Error( + `Fiscal period "${periodInfo.name ?? 'okänd'}" is closed. Unlock the period, or use omprövning for already-filed VAT.` + ) + } + + const originalLines = original.lines || [] + + return stagePendingOperation(supabase, companyId, userId, 'correct_entry', + `Rättelse: V${original.voucher_series}${original.voucher_number} — ${original.description}`, + { + entry_id: entryId, + lines, + }, + { + original: { + entry_id: entryId, + voucher: `${original.voucher_series}${original.voucher_number}`, + entry_date: original.entry_date, + description: original.description, + lines: originalLines.map((l) => ({ + account_number: l.account_number, + debit_amount: Number(l.debit_amount), + credit_amount: Number(l.credit_amount), + line_description: l.line_description, + })), + }, + correction: { + total_debit: balance.totalDebit, + total_credit: balance.totalCredit, + line_count: lines.length, + lines: lines.map((l) => ({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + line_description: l.line_description ?? null, + })), + }, + will: 'post a storno that mirrors the original, then post a new corrected entry, then mark the original as reversed (BFL 5 kap 5§)', + }, + actor + ) + }, + }, ] // ── MCP Protocol Handler ───────────────────────────────────── diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index c3185643..be22e401 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -121,6 +121,9 @@ export const TOOL_SCOPE_MAP: Record = { // Invoice conversion + crediting gnubok_convert_invoice: 'invoices:write', gnubok_credit_invoice: 'invoices:write', + // Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged) + gnubok_create_voucher: 'bookkeeping:write', + gnubok_correct_entry: 'bookkeeping:write', } export function validateScopes(scopes: unknown): ApiKeyScope[] | null { diff --git a/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts b/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts index b0f6274f..7c663d1f 100644 --- a/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts +++ b/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts @@ -66,6 +66,8 @@ describe('pending_operations: actor model + risk columns', () => { 'run_currency_revaluation', 'explain_voucher_gap', 'uncategorize_transaction', 'approve_supplier_invoice', 'credit_supplier_invoice', 'credit_invoice', 'convert_invoice', 'import_sie', + // Phase 4: arbitrary-line bookkeeping primitives + 'create_voucher', 'correct_entry', ] for (const op of expandedTypes) { diff --git a/lib/pending-operations/__tests__/risk-tiers.test.ts b/lib/pending-operations/__tests__/risk-tiers.test.ts index 0b3e1156..af588396 100644 --- a/lib/pending-operations/__tests__/risk-tiers.test.ts +++ b/lib/pending-operations/__tests__/risk-tiers.test.ts @@ -53,4 +53,14 @@ describe('risk-tiers', () => { expect(isHighRisk('create_customer')).toBe(false) expect(isHighRisk('categorize_transaction')).toBe(false) }) + + // Phase 4: arbitrary-line bookkeeping primitives. These accept any account + // and any amount from the caller, so they're HIGH despite being structurally + // similar to uncategorize_transaction (which is medium). + it('treats arbitrary-line voucher primitives as high risk', () => { + expect(getRiskLevel('create_voucher')).toBe('high') + expect(getRiskLevel('correct_entry')).toBe('high') + expect(isHighRisk('create_voucher')).toBe(true) + expect(isHighRisk('correct_entry')).toBe(true) + }) }) diff --git a/lib/pending-operations/__tests__/voucher-executors.test.ts b/lib/pending-operations/__tests__/voucher-executors.test.ts new file mode 100644 index 00000000..6f133059 --- /dev/null +++ b/lib/pending-operations/__tests__/voucher-executors.test.ts @@ -0,0 +1,425 @@ +/** + * Unit tests for commitCreateVoucher and commitCorrectEntry executors. + * The executors aren't exported individually, so we drive them through the + * public `commitPendingOperation` dispatcher. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase, makeJournalEntry } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +vi.mock('@/lib/bookkeeping/engine', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/engine' + ) + return { + ...actual, + createJournalEntry: vi.fn(), + findFiscalPeriod: vi.fn(), + } +}) + +vi.mock('@/lib/core/bookkeeping/storno-service', async () => { + const actual = await vi.importActual( + '@/lib/core/bookkeeping/storno-service' + ) + return { + ...actual, + correctEntry: vi.fn(), + } +}) + +import { commitPendingOperation } from '../commit' +import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { correctEntry } from '@/lib/core/bookkeeping/storno-service' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'create_voucher', + 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-12T00:00:00Z', + resolved_at: null, + updated_at: '2026-05-12T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +// ─── create_voucher ───────────────────────────────────────────────── + +describe('commitPendingOperation: create_voucher', () => { + it('happy path: posts a balanced entry with the provided fiscal_period_id', async () => { + vi.mocked(createJournalEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-100', voucher_number: 42, voucher_series: 'A' }) + ) + + 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({ + params: { + entry_date: '2026-05-12', + description: 'Capitalize Cursor subscription to 1010', + fiscal_period_id: 'fp-1', + lines: [ + { account_number: '1010', debit_amount: 250, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 250 }, + ], + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + journal_entry_id: 'je-100', + voucher_number: 42, + voucher_series: 'A', + fiscal_period_id: 'fp-1', + }) + expect(createJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + fiscal_period_id: 'fp-1', + entry_date: '2026-05-12', + description: 'Capitalize Cursor subscription to 1010', + source_type: 'manual', + }), + 'mcp_create_voucher' + ) + // findFiscalPeriod must NOT be called when fiscal_period_id is supplied — + // it's the caller's explicit choice. + expect(findFiscalPeriod).not.toHaveBeenCalled() + }) + + it('resolves fiscal_period from entry_date when omitted', async () => { + vi.mocked(findFiscalPeriod).mockResolvedValueOnce('fp-resolved') + vi.mocked(createJournalEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-101', voucher_number: 7 }) + ) + + 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({ + params: { + entry_date: '2026-05-12', + description: 'no fiscal_period_id', + lines: [ + { account_number: '5410', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ fiscal_period_id: 'fp-resolved' }) + expect(findFiscalPeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', '2026-05-12') + }) + + it('returns 400 in Swedish when no fiscal period covers the date', async () => { + vi.mocked(findFiscalPeriod).mockResolvedValueOnce(null) + + 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({ + params: { + entry_date: '2027-12-31', + description: 'far-future date', + lines: [ + { account_number: '5410', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + 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(/räkenskapsperiod/i) + expect(createJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 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({ + params: { + entry_date: '2026-05-12', + // missing description and lines + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(createJournalEntry).not.toHaveBeenCalled() + }) + + it('hardcodes source_type to manual even if params.source_type is tampered', async () => { + vi.mocked(createJournalEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-tamper', voucher_number: 8 }) + ) + + 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({ + params: { + entry_date: '2026-05-12', + description: 'attempt to spoof source_type', + fiscal_period_id: 'fp-1', + // Direct DB insert or future stager could put anything here. + source_type: 'bank_transaction', + lines: [ + { account_number: '1010', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + // Critical assertion: source_type is ALWAYS 'manual', never the + // caller-supplied value. Bypassing this lets a tampered operation + // misrepresent the audit trail as a bank-feed or invoice entry. + expect(createJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ source_type: 'manual' }), + 'mcp_create_voucher' + ) + expect(createJournalEntry).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ source_type: 'bank_transaction' }), + expect.anything() + ) + }) + + it('returns 400 with Swedish error when params are unbalanced (tamper defense)', 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({ + params: { + entry_date: '2026-05-12', + description: 'tampered: debit ≠ credit', + fiscal_period_id: 'fp-1', + // The MCP tool validates balance before staging, but a hand-inserted + // pending_operations row could bypass that. The executor's own + // validateBalance() gate catches it before reaching the engine. + lines: [ + { account_number: '1010', debit_amount: 1000, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 800 }, + ], + }, + }) + + 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(/balanserar inte/i) + expect(createJournalEntry).not.toHaveBeenCalled() + }) +}) + +// ─── correct_entry ────────────────────────────────────────────────── + +describe('commitPendingOperation: correct_entry', () => { + it('happy path: posts storno + corrected for a posted entry in an open period', async () => { + vi.mocked(correctEntry).mockResolvedValueOnce({ + reversal: makeJournalEntry({ id: 'je-storno', voucher_number: 50 }), + corrected: makeJournalEntry({ id: 'je-corrected', voucher_number: 51 }), + }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'je-original', + status: 'posted', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) // executor's pre-flight fetch + enqueue({ data: null, error: null }) // dispatcher's commit update + + const op = makePendingOp({ + operation_type: 'correct_entry', + params: { + entry_id: 'je-original', + lines: [ + { account_number: '2645', debit_amount: 250, credit_amount: 0 }, + { account_number: '2614', debit_amount: 0, credit_amount: 250 }, + ], + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + original_entry_id: 'je-original', + storno_entry_id: 'je-storno', + corrected_entry_id: 'je-corrected', + storno_voucher_number: 50, + corrected_voucher_number: 51, + }) + expect(correctEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-original', + expect.arrayContaining([ + expect.objectContaining({ account_number: '2645' }), + expect.objectContaining({ account_number: '2614' }), + ]) + ) + }) + + it('returns 404 when the original entry does not exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // executor's pre-flight fetch (no row) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'correct_entry', + params: { + entry_id: 'je-missing', + lines: [ + { account_number: '5410', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + 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(404) + expect(correctEntry).not.toHaveBeenCalled() + }) + + it('returns 409 when the original entry is not posted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'je-draft', + status: 'draft', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) // executor's pre-flight fetch + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'correct_entry', + params: { + entry_id: 'je-draft', + lines: [ + { account_number: '5410', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + 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).toMatch(/bokförda verifikationer/) + expect(correctEntry).not.toHaveBeenCalled() + }) + + it('returns 409 with omprövning hint when the period is closed', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'je-original', + status: 'posted', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: true }, + }, + error: null, + }) // executor's pre-flight fetch + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'correct_entry', + params: { + entry_id: 'je-original', + lines: [ + { account_number: '5410', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + 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).toMatch(/omprövning/i) + expect(correctEntry).not.toHaveBeenCalled() + }) + + it('returns 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: 'correct_entry', + params: { + entry_id: 'je-1', + // missing lines + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(correctEntry).not.toHaveBeenCalled() + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 057d092e..81e24386 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -27,7 +27,8 @@ import { createInvoiceJournalEntry, createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' -import { reverseEntry } from '@/lib/bookkeeping/engine' +import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' +import { correctEntry } from '@/lib/core/bookkeeping/storno-service' import { closePeriod, lockPeriod, unlockPeriod } from '@/lib/core/bookkeeping/period-service' import { executeYearEndClosing, @@ -64,6 +65,8 @@ import type { InvoiceItem, AccountingMethod, CreditNote, + CreateJournalEntryLineInput, + JournalEntrySourceType, } from '@/types' const log = createLogger('pending-operations/commit') @@ -1592,6 +1595,171 @@ async function commitImportSie( } } +// ── Phase 4: arbitrary-line bookkeeping primitives ─────────────── + +/** + * Normalize raw JSON line input from pending_operations.params into the + * engine's typed line shape. Trusts shape because the MCP tool already + * validates via Zod before staging — defensive coercion only. + */ +function normalizeVoucherLines(raw: unknown): CreateJournalEntryLineInput[] { + if (!Array.isArray(raw)) return [] + return raw.map((l) => { + const line = l as Record + return { + account_number: String(line.account_number), + debit_amount: Number(line.debit_amount) || 0, + credit_amount: Number(line.credit_amount) || 0, + line_description: line.line_description ? String(line.line_description) : undefined, + currency: line.currency ? String(line.currency) : undefined, + amount_in_currency: line.amount_in_currency !== undefined ? Number(line.amount_in_currency) : undefined, + exchange_rate: line.exchange_rate !== undefined ? Number(line.exchange_rate) : undefined, + tax_code: line.tax_code ? String(line.tax_code) : undefined, + cost_center: line.cost_center ? String(line.cost_center) : undefined, + project: line.project ? String(line.project) : undefined, + } + }) +} + +async function commitCreateVoucher( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const entryDate = params.entry_date as string + const description = params.description as string + const lines = normalizeVoucherLines(params.lines) + + if (!entryDate || !description || lines.length < 2) { + return { error: 'entry_date, description, and at least two lines are required', status: 400 } + } + + // Re-validate balance defensively. The MCP tool already checks before + // staging, but a tampered or hand-inserted pending_operations row would + // bypass that gate. createDraftEntry runs the same check internally — this + // is for a cleaner 400 + Swedish error before reaching the engine. + const balance = validateBalance(lines) + if (!balance.valid) { + return { + error: `Verifikationen balanserar inte: debet ${balance.totalDebit} SEK, kredit ${balance.totalCredit} SEK.`, + status: 400, + } + } + + // Resolve fiscal period: prefer explicit, fall back to date lookup so the + // caller can post a voucher without first calling list_fiscal_periods. + let fiscalPeriodId = params.fiscal_period_id as string | undefined + if (!fiscalPeriodId) { + const resolved = await findFiscalPeriod(supabase, companyId, entryDate) + if (!resolved) { + return { + error: `Ingen öppen räkenskapsperiod täcker datumet ${entryDate}. Öppna en period eller välj ett annat datum.`, + status: 400, + } + } + fiscalPeriodId = resolved + } + + try { + const entry = await createJournalEntry( + supabase, + companyId, + userId, + { + fiscal_period_id: fiscalPeriodId, + entry_date: entryDate, + description, + // source_type is hardcoded — never trust params.source_type. The MCP + // tool stages 'manual', but a future direct-staging path could + // otherwise inject 'bank'/'invoice'/etc. and corrupt audit attribution. + source_type: 'manual' as JournalEntrySourceType, + voucher_series: (params.voucher_series as string) || undefined, + notes: (params.notes as string) || undefined, + lines, + }, + 'mcp_create_voucher' + ) + + return { + data: { + journal_entry_id: entry.id, + voucher_number: entry.voucher_number, + voucher_series: entry.voucher_series, + fiscal_period_id: fiscalPeriodId, + }, + } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Failed to create voucher', status: 500 } + } +} + +async function commitCorrectEntry( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const entryId = params.entry_id as string + const lines = normalizeVoucherLines(params.lines) + + if (!entryId || lines.length < 2) { + return { error: 'entry_id and at least two lines are required', status: 400 } + } + + // Pre-flight: verify the original is posted and its period is not locked. + // Falling into correctEntry without this returns a less helpful DB error and + // half-creates the storno before rolling back; surfacing the Swedish message + // here matches the period_locked UX everywhere else in the app. + const { data: original, error: origErr } = await supabase + .from('journal_entries') + .select('id, status, fiscal_period_id, fiscal_periods!inner(is_closed)') + .eq('id', entryId) + .eq('company_id', companyId) + .maybeSingle() + + if (origErr || !original) { + return { error: 'Verifikationen hittades inte.', status: 404 } + } + if (original.status !== 'posted') { + return { + error: `Endast bokförda verifikationer kan rättas. Aktuell status: ${original.status}. Drafts redigeras direkt.`, + status: 409, + } + } + const period = original.fiscal_periods as { is_closed?: boolean } | { is_closed?: boolean }[] | null + const periodClosed = Array.isArray(period) ? period[0]?.is_closed : period?.is_closed + if (periodClosed) { + return { + error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.', + status: 409, + } + } + + try { + // correctEntry() posts both the storno and the corrected entry into the + // SAME fiscal_period_id and entry_date as the original (see + // lib/core/bookkeeping/storno-service.ts:99,102,195,198). So a rättelse + // made in May 2026 for a December 2025 voucher correctly lands in 2025, + // keeping that period's balances consistent. The is_closed pre-flight + // above is what blocks corrections to already-locked periods. + const result = await correctEntry(supabase, companyId, userId, entryId, lines) + return { + data: { + original_entry_id: entryId, + storno_entry_id: result.reversal.id, + corrected_entry_id: result.corrected.id, + storno_voucher_number: result.reversal.voucher_number, + corrected_voucher_number: result.corrected.voucher_number, + }, + } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Failed to correct entry', status: 500 } + } +} + // ── Public dispatcher ──────────────────────────────────────────── /** @@ -1703,6 +1871,12 @@ export async function commitPendingOperation( case 'import_sie': result = await commitImportSie(supabase, userId, companyId, pendingOp.params) break + case 'create_voucher': + result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params) + break + case 'correct_entry': + result = await commitCorrectEntry(supabase, userId, companyId, pendingOp.params) + break default: return { status: 'failed', diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index 1aefd51a..d12c64eb 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -57,6 +57,13 @@ export const OPERATION_RISK_TIERS: Record = { create_supplier_invoice_from_inbox: 'medium', credit_invoice: 'high', convert_invoice: 'medium', + + // ── Phase 4: arbitrary-line bookkeeping primitives ───────────────── + // Both accept caller-supplied account/amount/period — unlike + // uncategorize_transaction (medium), which mirrors an existing entry. + // The arbitrary-line capability is what makes these compliance-critical. + create_voucher: 'high', + correct_entry: 'high', } export function getRiskLevel(operationType: string): RiskLevel { diff --git a/supabase/migrations/20260512135110_pending_operations_expand_types_phase4.sql b/supabase/migrations/20260512135110_pending_operations_expand_types_phase4.sql new file mode 100644 index 00000000..d916bc1b --- /dev/null +++ b/supabase/migrations/20260512135110_pending_operations_expand_types_phase4.sql @@ -0,0 +1,59 @@ +-- Phase 4: expose the bookkeeping engine's arbitrary-line primitives via MCP. +-- +-- Until now the only way to produce a journal entry via MCP was through a +-- preset workflow (categorize_transaction, create_invoice, ...). That blocks +-- legitimate flows the engine already supports — capitalization to balance- +-- sheet accounts (BAS 1010 development costs under K3), period-end accruals, +-- FX adjustments outside the built-in revaluation, prepayments, manual +-- reclassifications, and rättelseposter for foreign reverse-charge VAT. +-- +-- Two new op types: +-- * create_voucher — arbitrary balanced lines via createJournalEntry() +-- * correct_entry — storno + replacement via correctEntry() (BFL 5 kap 5§) +-- +-- Both are routed as HIGH risk in lib/pending-operations/risk-tiers.ts because +-- they accept arbitrary account/amount/period inputs, unlike +-- uncategorize_transaction which mirrors an existing entry's shape. + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + -- Phase 0: original 7 op types + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + -- Stream 1 Phase 1: bookkeeping period operations + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + -- Stream 1 Phase 1: SIE import (export is read-only) + 'import_sie', + -- Stream 1 Phase 1: voucher gap explanations + 'explain_voucher_gap', + -- Stream 1 Phase 1: transaction reversal + 'uncategorize_transaction', + -- Stream 1 Phase 1: supplier invoice lifecycle + 'approve_supplier_invoice', + 'credit_supplier_invoice', + -- Stream 1 Phase 1: invoice operations beyond simple create/send + 'credit_invoice', + 'convert_invoice', + -- Phase 3: manual transaction ingestion + document attachment + 'create_transaction', + 'attach_document_to_transaction', + -- Phase 4: arbitrary-line bookkeeping primitives (this migration) + 'create_voucher', + 'correct_entry' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index d1f75c31..53756b3b 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1333,6 +1333,9 @@ export type PendingOperationType = // Stream 1 Phase 1: invoice operations beyond simple create/send | 'credit_invoice' | 'convert_invoice' + // Phase 4: arbitrary-line bookkeeping primitives + | 'create_voucher' + | 'correct_entry' export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'