Files
accounted/lib/pending-operations/__tests__/risk-tiers.test.ts
T
Jakob WennbergandClaude Opus 4.7 eb77ad50b5 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>
2026-05-12 17:16:39 +02:00

67 lines
2.5 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { getRiskLevel, isHighRisk, OPERATION_RISK_TIERS } from '../risk-tiers'
describe('risk-tiers', () => {
it('classifies all currently-staged op types', () => {
// Op types that exist in the pending_operations CHECK constraint today.
const knownOps = [
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
]
for (const op of knownOps) {
expect(OPERATION_RISK_TIERS).toHaveProperty(op)
}
})
it('treats sending invoices and marking paid as high risk', () => {
expect(getRiskLevel('send_invoice')).toBe('high')
expect(getRiskLevel('mark_invoice_paid')).toBe('high')
expect(getRiskLevel('mark_invoice_sent')).toBe('high')
})
it('treats period close, year-end, and SIE import as high risk', () => {
expect(getRiskLevel('close_period')).toBe('high')
expect(getRiskLevel('lock_period')).toBe('high')
expect(getRiskLevel('run_year_end')).toBe('high')
expect(getRiskLevel('import_sie')).toBe('high')
expect(getRiskLevel('set_opening_balances')).toBe('high')
})
it('treats customer creation as low risk (no booking impact)', () => {
expect(getRiskLevel('create_customer')).toBe('low')
})
it('treats reversible bookings as medium risk', () => {
expect(getRiskLevel('categorize_transaction')).toBe('medium')
expect(getRiskLevel('match_transaction_invoice')).toBe('medium')
expect(getRiskLevel('create_invoice')).toBe('medium')
expect(getRiskLevel('uncategorize_transaction')).toBe('medium')
})
it('defaults unknown op types to high (fail-safe)', () => {
expect(getRiskLevel('totally_unknown_op')).toBe('high')
expect(isHighRisk('totally_unknown_op')).toBe(true)
})
it('isHighRisk returns true only for high-risk ops', () => {
expect(isHighRisk('send_invoice')).toBe(true)
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)
})
})