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
+2
View File
@@ -194,6 +194,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_reconcile_match: 'reconciliation:write',
gnubok_reconcile_unmatch: 'reconciliation:write',
gnubok_reconcile_signoff: 'reconciliation:signoff',
// Residual booking writes a verifikat: the same scope that books a bank row.
gnubok_reconcile_residual: 'transactions:write',
gnubok_bulk_book_transactions: 'transactions:write',
gnubok_bulk_book_inbox_items: 'transactions:write',
gnubok_auto_match_period: 'transactions:write',
@@ -0,0 +1,117 @@
/**
* commitReconciliationResidual, driven through the public commitPendingOperation
* dispatcher. The booking itself lives in lib/reconciliation/residual.ts (unit
* tested there); these tests cover the wiring: param validation, the
* refusal-to-status mapping, and the committed payload.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
const residualMock = vi.fn()
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 { ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { commitPendingOperation } from '../commit'
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'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'reconciliation_residual',
status: 'pending',
title: 'test',
params: { account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' },
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-08-25T00:00:00Z',
resolved_at: null,
updated_at: '2026-08-25T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: reconciliation_residual', () => {
it('fails 400 when the staged params are incomplete, without booking', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({ params: { account_key: KEY, external_ids: [], journal_entry_id: E1, kind: 'bank_fee' } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(residualMock).not.toHaveBeenCalled()
})
it('books and links through the shared service and returns the residual verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's committed update
residualMock.mockResolvedValue({
dry_run: false,
residual_journal_entry_id: 'res-1',
residual_amount: -10,
applied: [{ external_id: T1, journal_entry_id: E1 }],
skipped: [],
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(residualMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
KEY,
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined },
{ dryRun: false },
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ account_key: KEY, residual_journal_entry_id: 'res-1', residual_amount: -10 })
})
it('maps a policy refusal to 400 with the residual code, and missing rows to a 404 auto-reject', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null })
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('över taket', 'RESIDUAL_TOO_LARGE'))
const refused = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(refused.status).toBe('failed')
expect(refused.http_status).toBe(400)
expect(refused.code).toBe('RESIDUAL_TOO_LARGE')
const second = createQueuedMockSupabase()
second.enqueue({ data: { id: 'op-1' }, error: null })
second.enqueue({ data: null, error: null })
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND'))
const missing = await commitPendingOperation(second.supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(missing.status).toBe('rejected')
expect(missing.http_status).toBe(404)
})
it('404s an account key the company does not own', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null })
residualMock.mockResolvedValueOnce(null)
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
})
})
+62
View File
@@ -25,6 +25,7 @@ import {
import { roundOre } from '@/lib/money'
import { matchPairs, unmatchLink } from '@/lib/reconciliation/actions'
import { signOffAccount } from '@/lib/reconciliation/signoff'
import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { validateVatNumber } from '@/lib/vat/vies-client'
import {
@@ -6085,6 +6086,64 @@ async function commitReconciliationSignoff(
}
}
/**
* reconciliation_residual: book the remainder of a bank selection as a small
* fee / interest / rounding verifikat and link the selection, through the same
* service the page and the v1 API use (lib/reconciliation/residual.ts). The
* amount and direction are recomputed at commit time; a refusal (grown past
* the cap, rows linked meanwhile, locked period) leaves nothing half done.
*/
async function commitReconciliationResidual(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const accountKey = params.account_key as string | undefined
const externalIds = params.external_ids as string[] | undefined
const journalEntryId = params.journal_entry_id as string | undefined
const kind = params.kind as 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense' | undefined
if (!accountKey || !Array.isArray(externalIds) || externalIds.length === 0 || !journalEntryId || !kind) {
return { error: 'account_key, external_ids, journal_entry_id and kind are required', status: 400 }
}
try {
const result = await bookResidualAndLink(
supabase,
companyId,
userId,
accountKey,
{
external_ids: externalIds,
journal_entry_id: journalEntryId,
kind,
entry_date: (params.entry_date as string | undefined) ?? undefined,
description: (params.description as string | undefined) ?? undefined,
},
{ dryRun: false },
)
if (!result) return { error: `Unknown account_key ${accountKey}`, status: 404 }
if (result.dry_run) return { error: 'Unexpected dry-run result', status: 500 }
return {
data: {
account_key: accountKey,
residual_journal_entry_id: result.residual_journal_entry_id,
residual_amount: result.residual_amount,
applied: result.applied,
skipped: result.skipped,
},
}
} catch (err) {
if (err instanceof ReconciliationResidualError) {
return {
error: err.message,
errorCode: err.code,
status: err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 404 : 400,
}
}
throw err
}
}
async function commitLinkTransactionJournalEntry(
supabase: SupabaseClient,
userId: string,
@@ -6429,6 +6488,9 @@ async function commitPendingOperationInner(
case 'reconciliation_signoff':
result = await commitReconciliationSignoff(supabase, userId, companyId, pendingOp.params)
break
case 'reconciliation_residual':
result = await commitReconciliationResidual(supabase, userId, companyId, pendingOp.params)
break
case 'submit_vat_declaration':
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -210,6 +210,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// Sign-off writes the attestation row others rely on (overview, Hem, auditor)
// but nothing in the ledger, and reopen undoes it: 'medium'.
reconciliation_signoff: 'medium',
// Residual booking writes one small verifikat (bank fee / interest /
// rounding, capped at RESIDUAL_MAX_AMOUNT) against the bank account and
// links the selection: a typed, bounded booking like categorize_transaction,
// undone by storno + unmatch, so 'medium' rather than create_voucher's 'high'.
reconciliation_residual: 'medium',
// ── Körjournal (mileage) ───────────────────────────────────────────
// A trip row is pure travel documentation: no booking impact until a