feat(mcp): gnubok_reconcile_residual stages residual booking + link on a bank account (#1872)

The residual door existed for the page and the v1 API (#1862) but not for
agents: an MCP client that found a 10 kr bank fee between a selection and its
verifikat had to hand the last step back to the user. gnubok_reconcile_residual
dry-runs lib/reconciliation/residual.ts at stage time (so zero / cap /
direction / skattekonto refusals surface immediately), stages a
reconciliation_residual operation with the would-book verifikat as the
preview, and commitReconciliationResidual links and books on approval.

Risk 'medium' (one typed verifikat bounded by RESIDUAL_MAX_AMOUNT, undone by
storno + unmatch); scope transactions:write like the v1 route. The op type
is added to the pending_operations CHECK (NOT VALID + VALIDATE pair, list
verified against the live prod constraint 2026-08-25), and the tool joins
the reconcile_month / close_period loadouts and the reconcile-month skill.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 09:03:43 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 9ebb2e518f
commit 1e6e19afe9
12 changed files with 457 additions and 2 deletions
+74
View File
@@ -173,6 +173,7 @@ import { getAccountStatus } from '@/lib/reconciliation/service'
import { listAccountItems } from '@/lib/reconciliation/items'
import { matchPairs } from '@/lib/reconciliation/actions'
import { signOffAccount } from '@/lib/reconciliation/signoff'
import { bookResidualAndLink, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual'
import { parseAccountKey, type ReconciliationItemBucket } from '@/lib/reconciliation/schemas'
import { decryptPersonnummer, maskEmployeeForResponse, maskPersonnummer } from '@/lib/salary/personnummer'
import {
@@ -1388,6 +1389,7 @@ const TOOL_PREFLIGHT_MAP: Record<string, string> = {
gnubok_book_salary_run: 'gnubok_get_salary_run',
gnubok_reconcile_match: 'gnubok_get_reconciliation_status',
gnubok_reconcile_signoff: 'gnubok_get_reconciliation_status',
gnubok_reconcile_residual: 'gnubok_get_reconciliation_status',
}
/**
@@ -10201,6 +10203,78 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_reconcile_residual',
title: 'Reconcile: Book Residual and Link',
description: 'Close a near-match on a bank account in one step: link 1..50 bank tx to one verifikat and book the small difference as bank fee (6570), interest (8410/8310) or rounding (3740). Bank only; refused at 0, above the cap, or wrong direction. Stages; dry_run previews.',
catalogVisibility: 'search',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
account_key: { type: 'string', description: '"bank:<cash_account_id>" (skattekonto is refused: Skatteverket posts ränta and avgifter as rows of their own).' },
external_ids: { type: 'array', items: { type: 'string' }, description: 'transaction_id list (1..50) that together settle the verifikat except for the residual.' },
journal_entry_id: { type: 'string', description: 'The verifikat the transactions belong to.' },
kind: { type: 'string', enum: ['bank_fee', 'interest_expense', 'interest_income', 'rounding'], description: 'What the difference is. bank_fee / interest_expense: money left the bank unbooked; interest_income: money arrived unbooked; rounding: either way.' },
entry_date: { type: 'string', description: 'YYYY-MM-DD for the residual verifikat. Default: the latest transaction date.' },
description: { type: 'string', description: 'Verifikat text. Default per kind.' },
dry_run: { type: 'boolean' },
idempotency_key: { type: 'string' },
},
required: ['account_key', 'external_ids', 'journal_entry_id', 'kind'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const accountKey = args.account_key as string
const externalIds = args.external_ids as string[]
const journalEntryId = args.journal_entry_id as string
const kind = args.kind as 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense'
if (!parseAccountKey(accountKey)) throw new Error(`Invalid account_key "${accountKey}"`)
if (!Array.isArray(externalIds) || externalIds.length === 0 || externalIds.length > 50) {
throw new Error('external_ids must hold 1..50 transaction ids')
}
// Policy runs now (dry run of the booking) so a refusal (zero, above the
// cap, wrong direction, skattekonto) surfaces here, not at approval time;
// the executor recomputes the residual when the user approves.
const input = {
external_ids: externalIds,
journal_entry_id: journalEntryId,
kind,
entry_date: args.entry_date as string | undefined,
description: args.description as string | undefined,
}
const preview = await bookResidualAndLink(supabase, companyId, userId, accountKey, input, { dryRun: true })
if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`)
if (!preview.dry_run) throw new Error('Unexpected live result from a dry run')
const wouldBook = preview.would_book
return stagePendingOperation(
supabase,
companyId,
userId,
'reconciliation_residual',
`Bokför restpost ${wouldBook.residual_amount} ${wouldBook.currency} (${kind}) på ${accountKey} och koppla ${externalIds.length} rad(er)`,
{ account_key: accountKey, ...input },
{ ...wouldBook, account_key: accountKey, transaction_count: externalIds.length, max_amount: RESIDUAL_MAX_AMOUNT },
actor,
{
description: 'After approval, re-read the bridge: the selection is matched and the residual verifikat anchors on the first transaction.',
tool: 'gnubok_get_reconciliation_status',
args: { account_key: accountKey },
},
{
dryRun: args.dry_run === true,
idempotencyKey: args.idempotency_key as string | undefined,
},
)
},
},
{
name: 'gnubok_list_cash_accounts',
title: 'List Cash Accounts',