feat(mcp): add create_voucher + correct_entry MCP tools (#448)
* feat(mcp): add create_voucher + correct_entry MCP tools The MCP toolset had no way to post a journal entry outside the preset workflows (categorize_transaction, create_invoice, …). That blocks legitimate flows the engine already supports — K3 capitalization to BAS 1010, period-end accruals, FX adjustments, prepayments, and rättelseposter for foreign reverse-charge VAT that landed on 2641 instead of 2614/2645. create_voucher exposes the existing createJournalEntry() primitive: arbitrary balanced lines, optional fiscal-period auto-resolution, staged for human approval. correct_entry exposes correctEntry() (storno + new corrected entry per BFL 5 kap 5§) so part of a posted verifikation can be fixed without losing the legs that were right. Both are HIGH risk in OPERATION_RISK_TIERS — the arbitrary account/amount/ period inputs make them compliance-critical despite being structurally similar to uncategorize_transaction (medium). Approval flow unchanged; no auto-commit, regardless of trust level. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): address PR #448 review — voucher tools hardening Greptile P1 + compliance bot findings, all in one pass. commitCreateVoucher (commit.ts): - Hardcode source_type to 'manual' instead of reading from params. A future direct-staging path or hand-inserted pending_operations row could otherwise inject 'bank_transaction'/'invoice_created'/etc. and corrupt the audit-trail origin. - Re-validate balance defensively before reaching the engine, so a tampered params row surfaces a clean Swedish 400 instead of an opaque engine error. gnubok_create_voucher (server.ts): - Validate the explicit fiscal_period_id when supplied: confirm it exists, is open (is_closed = false), and that entry_date falls within its span. Without this, a closed/locked period was only caught at commit-time with a generic DB-trigger error. - Throw at staging when any line targets an account that's missing from chart_of_accounts or marked inactive, rather than relying on the approver to spot the advisory flag. - Remove source_type from the staged params blob entirely — the executor ignores it anyway, no point letting it travel through. - Add a comment that the staging-time period-lock check is advisory and the executor is the authoritative guard, so future cleanup doesn't remove either as 'redundant'. Descriptions: - gnubok_correct_entry now explicitly notes that the storno + corrected entries land in the original period (defends against compliance bot's speculative "different period" concern recurring on future reviews). - Both tools' tax_code field gets a note that the BAS account number drives momsdeklaration ruta mapping, not tax_code — guards against an LLM treating tax_code as the VAT-routing dial. commitCorrectEntry (commit.ts): - Add a comment pointing at storno-service.ts:99,102,195,198 to make the "uses original period and date" invariant explicit in this file. Tests: - +2 voucher-executors cases: source_type tampering is ignored, unbalanced params return 400. - +10 new voucher-tools tests (MCP layer): unbalanced, closed explicit period, missing explicit period, entry_date outside period, unknown account, inactive account, happy path + correct_entry registration + unbalanced replacement. 720 tests pass in the impacted suites; full suite 2998/2998. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof import('@/lib/bookkeeping/engine')>(
|
||||
'@/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<string, unknown> }
|
||||
|
||||
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<typeof vi.fn>).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)
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, unknown>> | 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<string, { name: string; active: boolean }>()
|
||||
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<Record<string, unknown>> | 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 ─────────────────────────────────────
|
||||
|
||||
@@ -121,6 +121,9 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof import('@/lib/bookkeeping/engine')>(
|
||||
'@/lib/bookkeeping/engine'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
createJournalEntry: vi.fn(),
|
||||
findFiscalPeriod: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/storno-service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/core/bookkeeping/storno-service')>(
|
||||
'@/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>): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
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<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
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',
|
||||
|
||||
@@ -57,6 +57,13 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user