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
+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