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:
Jakob Wennberg
2026-05-12 17:16:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ec06a2d04d
commit eb77ad50b5
10 changed files with 1292 additions and 2 deletions
@@ -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)
})
})
+329 -1
View File
@@ -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 ─────────────────────────────────────