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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
9ebb2e518f
commit
1e6e19afe9
@@ -11,6 +11,7 @@ const statusMock = vi.fn()
|
||||
const itemsMock = vi.fn()
|
||||
const matchMock = vi.fn()
|
||||
const signoffMock = vi.fn()
|
||||
const residualMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/reconciliation/service', () => ({
|
||||
getAccountStatus: (...args: unknown[]) => statusMock(...args),
|
||||
@@ -28,6 +29,10 @@ vi.mock('@/lib/reconciliation/actions', () => ({
|
||||
vi.mock('@/lib/reconciliation/signoff', () => ({
|
||||
signOffAccount: (...args: unknown[]) => signoffMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/reconciliation/residual', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
|
||||
return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) }
|
||||
})
|
||||
|
||||
import { tools, isDefaultCatalogTool, deriveToolMeta } from '../server'
|
||||
|
||||
@@ -225,3 +230,83 @@ describe('gnubok_reconcile_signoff', () => {
|
||||
expect(signoffMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_reconcile_residual', () => {
|
||||
const CASH = '11111111-1111-4111-8111-111111111111'
|
||||
const KEY = `bank:${CASH}`
|
||||
const T1 = '22222222-2222-4222-8222-222222222222'
|
||||
const E1 = '44444444-4444-4444-8444-444444444444'
|
||||
const wouldBook = {
|
||||
kind: 'bank_fee',
|
||||
counter_account: '6570',
|
||||
ledger_account: '1930',
|
||||
currency: 'SEK',
|
||||
transactions_total: -1010,
|
||||
entry_net: -1000,
|
||||
residual_amount: -10,
|
||||
entry_date: '2026-07-31',
|
||||
description: 'Bankavgift',
|
||||
lines: [
|
||||
{ account_number: '6570', debit_amount: 10, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 10 },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
residualMock.mockReset()
|
||||
})
|
||||
|
||||
it('is search-only, requires approval, and preflights on the status tool', () => {
|
||||
expect(isDefaultCatalogTool(tool('gnubok_reconcile_residual'))).toBe(false)
|
||||
expect(deriveToolMeta(tool('gnubok_reconcile_residual'))).toMatchObject({
|
||||
requires_approval: true,
|
||||
preflight: 'gnubok_get_reconciliation_status',
|
||||
})
|
||||
})
|
||||
|
||||
it('dry-runs the booking first and stages reconciliation_residual with the verifikat preview', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
residualMock.mockResolvedValue({ dry_run: true, would_book: wouldBook })
|
||||
const out = (await tool('gnubok_reconcile_residual').execute(
|
||||
{ account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', dry_run: true },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
)) as Record<string, unknown>
|
||||
expect(residualMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
USER,
|
||||
KEY,
|
||||
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined },
|
||||
{ dryRun: true },
|
||||
)
|
||||
expect(out).toMatchObject({ staged: false, dry_run: true, risk_level: 'medium' })
|
||||
expect(out.next).toMatchObject({ tool: 'gnubok_get_reconciliation_status' })
|
||||
expect(out.preview).toMatchObject({ account_key: KEY, residual_amount: -10, counter_account: '6570', transaction_count: 1 })
|
||||
})
|
||||
|
||||
it('surfaces a policy refusal (zero, cap, direction, skattekonto) instead of staging', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
residualMock.mockRejectedValue(new Error('Restposten pekar åt fel håll för bank_fee.'))
|
||||
await expect(
|
||||
tool('gnubok_reconcile_residual').execute(
|
||||
{ account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' },
|
||||
COMPANY,
|
||||
USER,
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/fel håll/)
|
||||
})
|
||||
|
||||
it('rejects a malformed account_key and an empty selection before touching anything', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tool('gnubok_reconcile_residual').execute({ account_key: '1930', external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/Invalid account_key/)
|
||||
await expect(
|
||||
tool('gnubok_reconcile_residual').execute({ account_key: KEY, external_ids: [], journal_entry_id: E1, kind: 'bank_fee' }, COMPANY, USER, supabase as never),
|
||||
).rejects.toThrow(/1\.\.50/)
|
||||
expect(residualMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,7 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
||||
// staged link (bank accounts and skattekonto alike).
|
||||
'gnubok_list_reconciliation_items',
|
||||
'gnubok_reconcile_match',
|
||||
'gnubok_reconcile_residual',
|
||||
'gnubok_reconcile_signoff',
|
||||
'gnubok_list_voucher_gaps',
|
||||
'gnubok_explain_voucher_gap',
|
||||
@@ -84,6 +85,9 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
||||
'gnubok_list_reconciliation_items',
|
||||
'gnubok_reconcile_match',
|
||||
'gnubok_reconcile_unmatch',
|
||||
// Near-miss on a bank account (fee, interest, rounding): link and book
|
||||
// the difference in one staged step.
|
||||
'gnubok_reconcile_residual',
|
||||
// Rows with no counterpart: book them (bank side) or link to the
|
||||
// verifikat that already holds the affärshändelse.
|
||||
'gnubok_categorize_transaction',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -46,14 +46,14 @@ Per account: outside vs ledger, what was linked, what the user still has to book
|
||||
## Rules
|
||||
|
||||
- Links and sign-offs never touch the ledger; booking does, and always stages.
|
||||
- One outside row links to one verifikat in this version; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A fee or rounding difference needs a residual booking by the user first.
|
||||
- One or more outside rows link to one verifikat; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A small fee, interest or rounding difference on a bank account is closed with \`gnubok_reconcile_residual({ account_key, external_ids, journal_entry_id, kind, dry_run: true })\`, then without dry_run: it links the rows and books the difference (6570 / 8410 / 8310 / 3740) in one staged step. Anything larger than the cap is a missing booking, not a fee.
|
||||
- Never judge on \`difference\`; the bridge explains it. Judge on \`unexplained_difference\`.
|
||||
- A skattekonto sign-off date cannot pass the saldo snapshot; ask for a fetch.
|
||||
|
||||
## Tools used
|
||||
|
||||
- \`gnubok_get_reconciliation_status\`, \`gnubok_list_reconciliation_items\` (read)
|
||||
- \`gnubok_reconcile_match\`, \`gnubok_reconcile_unmatch\`, \`gnubok_reconcile_signoff\` (staged writes)
|
||||
- \`gnubok_reconcile_match\`, \`gnubok_reconcile_unmatch\`, \`gnubok_reconcile_residual\`, \`gnubok_reconcile_signoff\` (staged writes)
|
||||
- \`gnubok_categorize_transaction\`, \`gnubok_link_transaction_to_journal_entry\` (bank-side booking)
|
||||
- \`gnubok_approve_pending_operation\` (when the user approves in chat)
|
||||
- Resource: \`Accounted://reconciliation/summary\`
|
||||
|
||||
Reference in New Issue
Block a user