* 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>
280 lines
8.3 KiB
TypeScript
280 lines
8.3 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|