* 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>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|