fix(mcp): reject unparseable voucher lines and allocation kinds; already-booked and unlinkable say why; N:1 reconcile groups survive staging (#2171)
* fix(mcp): reject unparseable voucher lines and allocation kinds; already-booked and unlinkable say why; N:1 reconcile groups survive staging
Four MCP feedback reports about the same failure class: the server does no
runtime validation of inputSchema, so a shape mistake was coerced into a
wrong-but-well-formed call, and the error the agent finally saw pointed at
the wrong thing.
- create_voucher / correct_entry: a line naming neither debit_amount nor
credit_amount was `Number(undefined) || 0`-ed into 0/0, and the balance
check reported "debits 0 SEK, credits 0 SEK" for four perfectly balanced
formats ({debit}, {amount, side}, {debitAmount}, signed amount). The line
shape is now checked first, the error names the keys it got and shows a
valid line, and a non-numeric amount is rejected as such (seq 318571).
- match_batch_allocate: kind is the key every guard branches on (direction
vs sign, required id per kind, tenant pre-check on the invoices). With
kind absent none of them fired: an incoming +50 359 SEK payment against
three kundfakturor staged as allocations_kind "supplier_invoice" with zero
invoice checks. A missing or unknown kind is now rejected before any
query, with the id field that goes with each kind (seq 319919).
- categorize_transaction on an already-booked transaction returned the
core's success-shaped object, which fails STAGED_OPERATION_SCHEMA on
strict clients: the agent saw "Structured content does not match the
tool's output schema" and never the reason. It now throws, naming the
existing journal_entry_id (seq 288574).
- reconcile_match: the dry run flattens a pair into one link per outside
row, and the staging rebuild put each back as its own 1:1 pair, so an N:1
group (Skatteverket "Avdragen skatt" + "Arbetsgivaravgift" against one
1630 verifikat, sum exact) reached the executor as N pairs each asked to
settle the whole verifikat: PAIR_NOT_CLOSED on all 18. Links sharing a
verifikat now fold back into one pair, mirroring the existing 1:N fold,
and "No linkable pairs" carries the dry run's skip reasons (seq 292682).
No tools/list payload change: no schema text touched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3
* docs(decisions): N:1 reconcile fold at staging
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
f767977bf5
commit
67e878fb23
@@ -1523,6 +1523,35 @@ async function categorizeTransactionCore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A voucher line that names neither debit_amount nor credit_amount is a shape
|
||||
* error, not a zero line. `Number(undefined) || 0` silently turned
|
||||
* {debit: 500}, {amount, side} and {debitAmount: 500} into 0/0, and the
|
||||
* balance check then reported "debits 0 SEK, credits 0 SEK" for four
|
||||
* perfectly balanced formats (feedback seq 318571). No host validates
|
||||
* inputSchema at runtime, so the guard lives here, and it names the keys it
|
||||
* got so the agent can fix the shape in one turn instead of guessing at the
|
||||
* amounts.
|
||||
*/
|
||||
function assertVoucherLineShape(line: Record<string, unknown>, label: string): void {
|
||||
const hasDebit = line.debit_amount !== undefined && line.debit_amount !== null
|
||||
const hasCredit = line.credit_amount !== undefined && line.credit_amount !== null
|
||||
if (!hasDebit && !hasCredit) {
|
||||
const got = Object.keys(line).filter((k) => k !== 'account_number')
|
||||
throw new Error(
|
||||
`${label}: expected debit_amount and/or credit_amount (numbers in SEK)` +
|
||||
(got.length ? `; got ${got.join(', ')}` : '; got neither') +
|
||||
'. Example: {"account_number":"6110","debit_amount":400}, {"account_number":"1930","credit_amount":400}.',
|
||||
)
|
||||
}
|
||||
for (const key of ['debit_amount', 'credit_amount'] as const) {
|
||||
const v = line[key]
|
||||
if (v !== undefined && v !== null && !Number.isFinite(Number(v))) {
|
||||
throw new Error(`${label}.${key} must be a number in SEK; got ${JSON.stringify(v)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output schema helpers ────────────────────────────────────
|
||||
|
||||
const PAGINATION_PROPS = {
|
||||
@@ -5696,10 +5725,18 @@ export const tools: McpTool[] = [
|
||||
false // preview mode: execution happens at approval time via gnubok_approve_pending_operation
|
||||
)
|
||||
|
||||
// If already has a journal entry, pass through as-is
|
||||
// Already booked: categorizeTransactionCore returns a success-shaped
|
||||
// object here, which fails STAGED_OPERATION_SCHEMA on strict clients and
|
||||
// reached the agent as "Structured content does not match the tool's
|
||||
// output schema" with the real reason swallowed (feedback seq 288574).
|
||||
// Throw instead: the dispatcher's isError path carries no
|
||||
// structuredContent, so the message survives.
|
||||
if (result.success && result.journal_entry_created === false) {
|
||||
const { transaction: _tx, ...publicResult } = result
|
||||
return publicResult
|
||||
throw new Error(
|
||||
`Transaction is already booked (journal_entry_id ${result.journal_entry_id ?? 'unknown'}); ` +
|
||||
'nothing to categorize. Use gnubok_list_uncategorized_transactions to find unbooked ones, ' +
|
||||
'or gnubok_correct_entry / gnubok_reverse_journal_entry to change the existing verifikat.',
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch transaction description (and date for period_status) for the title
|
||||
@@ -10693,6 +10730,21 @@ export const tools: McpTool[] = [
|
||||
if (!Array.isArray(allocations) || allocations.length === 0) {
|
||||
throw new Error('allocations is required (non-empty array)')
|
||||
}
|
||||
// kind is the key every guard below branches on (direction, required
|
||||
// id, tenant pre-check). With kind absent, none of them fired: an
|
||||
// incoming +50 359 SEK payment against three kundfakturor staged as
|
||||
// allocations_kind "supplier_invoice" with zero invoice checks
|
||||
// (feedback seq 319919). No host validates inputSchema at runtime, so
|
||||
// reject here, and say which id field goes with which kind.
|
||||
for (const [i, a] of allocations.entries()) {
|
||||
if (a.kind !== 'customer_invoice' && a.kind !== 'supplier_invoice') {
|
||||
throw new Error(
|
||||
`allocations[${i}].kind is required: "customer_invoice" (incoming payment, pass invoice_id) ` +
|
||||
`or "supplier_invoice" (outgoing payment, pass supplier_invoice_id)` +
|
||||
(a.kind === undefined ? '' : `; got ${JSON.stringify(a.kind)}`),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
@@ -12149,15 +12201,22 @@ export const tools: McpTool[] = [
|
||||
{ dryRun: true },
|
||||
)
|
||||
if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`)
|
||||
// Rebuild the staged pairs from the preview: 1:1 links stay one pair
|
||||
// each; the links of a bank 1:N split (they carry allocated_amount, all
|
||||
// on the same row) fold back into ONE pair with explicit allocations, so
|
||||
// the executor re-validates the exact slices the reviewer approved.
|
||||
// Rebuild the staged pairs from the preview. The dry run flattens every
|
||||
// pair into one link per outside row, so the grouping must be put back:
|
||||
// the links of an N:1 pair (several rows, one verifikat, no
|
||||
// allocated_amount) fold into ONE pair on their verifikat, and the links
|
||||
// of a bank 1:N split (one row, allocated_amount per verifikat) fold into
|
||||
// ONE pair with explicit allocations. Staging them as N separate 1:1
|
||||
// pairs made the executor ask each Skatteverket row alone to settle the
|
||||
// whole 1630 verifikat: PAIR_NOT_CLOSED on every "Avdragen skatt" +
|
||||
// "Arbetsgivaravgift" pair whose sum matched exactly (feedback seq
|
||||
// 292682, 36 rows / 18 verifikat).
|
||||
const resolvedPairs: Array<{
|
||||
external_ids: string[]
|
||||
journal_entry_ids: string[]
|
||||
allocations?: Array<{ journal_entry_id: string; amount: number }>
|
||||
}> = []
|
||||
const rowsByEntry = new Map<string, string[]>()
|
||||
const splitByRow = new Map<string, Array<{ journal_entry_id: string; amount: number }>>()
|
||||
for (const a of preview.applied) {
|
||||
if (typeof a.allocated_amount === 'number') {
|
||||
@@ -12166,7 +12225,12 @@ export const tools: McpTool[] = [
|
||||
splitByRow.set(a.external_id, slices)
|
||||
continue
|
||||
}
|
||||
resolvedPairs.push({ external_ids: [a.external_id], journal_entry_ids: [a.journal_entry_id] })
|
||||
const rows = rowsByEntry.get(a.journal_entry_id) ?? []
|
||||
if (!rows.includes(a.external_id)) rows.push(a.external_id)
|
||||
rowsByEntry.set(a.journal_entry_id, rows)
|
||||
}
|
||||
for (const [journalEntryId, externalIds] of rowsByEntry) {
|
||||
resolvedPairs.push({ external_ids: externalIds, journal_entry_ids: [journalEntryId] })
|
||||
}
|
||||
for (const [externalId, slices] of splitByRow) {
|
||||
resolvedPairs.push({
|
||||
@@ -12176,7 +12240,15 @@ export const tools: McpTool[] = [
|
||||
})
|
||||
}
|
||||
if (resolvedPairs.length === 0) {
|
||||
throw new Error('No linkable pairs: nothing to stage')
|
||||
// The dry run's skipped list holds the actual reason; without it the
|
||||
// agent saw only "No linkable pairs" (feedback seq 292682).
|
||||
const reasons = preview.skipped
|
||||
.slice(0, 5)
|
||||
.map((sk) => `${sk.code}: ${sk.message}`)
|
||||
throw new Error(
|
||||
'No linkable pairs: nothing to stage' +
|
||||
(reasons.length ? `. Skipped: ${reasons.join(' | ')}` : ''),
|
||||
)
|
||||
}
|
||||
|
||||
return stagePendingOperation(
|
||||
@@ -19005,6 +19077,9 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
|
||||
// Normalize so validateBalance + preview see consistent numeric types.
|
||||
// Shape first: a line with neither amount key is a schema mismatch and
|
||||
// must say so, never coerce to 0/0 and fail the balance check instead.
|
||||
for (const [i, l] of rawLines.entries()) assertVoucherLineShape(l, `lines[${i}]`)
|
||||
const lines = rawLines.map((l, i) => ({
|
||||
account_number: String(l.account_number ?? ''),
|
||||
debit_amount: Number(l.debit_amount) || 0,
|
||||
@@ -19320,6 +19395,7 @@ export const tools: McpTool[] = [
|
||||
throw new Error('entry_id and at least two lines are required')
|
||||
}
|
||||
|
||||
for (const [i, l] of rawLines.entries()) assertVoucherLineShape(l, `lines[${i}]`)
|
||||
const lines = rawLines.map((l, i) => ({
|
||||
account_number: String(l.account_number ?? ''),
|
||||
debit_amount: Number(l.debit_amount) || 0,
|
||||
|
||||
Reference in New Issue
Block a user