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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user