Files
accounted/app/api/transactions/[id]/match-batch/route.ts
T
Jakob Wennberg cce0de5704 feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate

The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when
posted, unlinked vouchers already summed to the bank row (PR #2300), but the
MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate)
called the RPC with no guard, so an agent could book a Bankgirot aggregate a
second time. The detector existed once; the guard lived in one door.

One shared decision helper, lib/invoices/already-explained-guard.ts, now
sits on top of the existing detectors (no fork) and is called by the
dashboard route, the MCP staging tools and the commit executors:

- gnubok_match_batch_allocate refuses to stage, coded
  BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match /
  link_transaction_to_journal_entry call that resolves the row, and the
  exact force + expected_journal_entry_ids binding.
- commitMatchBatchAllocate runs the same guard before the RPC and
  re-validates a staged force binding against the set detected at commit,
  so a stale approval cannot book a duplicate; 409 auto-rejects with the
  vouchers in result_data.
- force + expected_journal_entry_ids on the tool mirror MatchBatchSchema;
  an honoured override stages with a compliance_warning and, after the
  booking succeeds, writes BankTransactionDuplicateDismissed to
  behandlingshistorik (dashboard route included; it only logged before).
- gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get
  the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE
  / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force +
  expected_journal_entry_id; at commit it runs before the storno.
- Registry: both duplicate codes gain retryable: false and a remediation.

Catalog payload held under the 60K ceiling by trimming the two tools' own
descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices

Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review)

Review round on PR #2346 (CodeRabbit + compliance):

- guardAlreadyExplained returned 'clear' when the detector threw even with
  force=true, so a forced 1:N override could book without re-validating
  expected_journal_entry_ids and left no behandlingshistorik record. It now
  returns a distinct 'unverifiable' outcome under force (mirrors
  guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool
  and the commit executor all refuse it with the new registry code
  BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression
  tests on every caller.
- A detector failure without force still fails open at stage time, but no
  longer silently: the tools track onDetectError and stage a
  complianceNote, so preview_data.compliance_warning is set on both
  match_batch_allocate (GenericPreview renders it) and
  match_transaction_invoice (MatchTransactionInvoicePreview now renders
  data.compliance_warning through AttnLine).
- expected_journal_entry_ids / expected_journal_entry_id are validated at
  the MCP boundary (array of 1 to 10 non-empty strings / non-empty string)
  and refused with VALIDATION_ERROR instead of being silently filtered.
  No schema description text added: catalog payload unchanged.
- RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history
  for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR
  2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:56:36 +02:00

262 lines
9.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MatchBatchSchema } from '@/lib/api/schemas'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { eventBus } from '@/lib/events/bus'
import { clearSettledBatchAllocationSuggestions } from '@/lib/invoices/clear-settled-batch-allocations'
import {
alreadyExplainedDetails,
guardAlreadyExplained,
recordExplainedOverride,
} from '@/lib/invoices/already-explained-guard'
import { ensureInitialized } from '@/lib/init'
import type { Invoice, SupplierInvoice, Transaction } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
interface RpcAllocationResult {
kind: 'customer_invoice' | 'supplier_invoice'
invoice_id?: string
supplier_invoice_id?: string
payment_id: string
status: 'paid' | 'partially_paid'
paid_amount: number
remaining_amount: number
amount: number
}
interface RpcOk {
ok: true
journal_entry_id: string
voucher_series: string
voucher_number: number
tx_id: string
allocations: RpcAllocationResult[]
total_allocated: number
leftover: number
}
interface RpcErr {
ok: false
code: string
details?: Record<string, unknown>
}
/**
* POST /api/transactions/[id]/match-batch
*
* Allocate one bank transaction across N customer OR N supplier invoices.
* Builds a single combined verifikat (samlingsverifikation) and inserts N
* payment rows via the match_batch_allocate PL/pgSQL RPC.
*
* The RPC is the atomicity boundary; this route is a thin wrapper that:
* 1. Validates the request body via MatchBatchSchema.
* 2. Invokes the RPC.
* 3. Maps the structured RPC error (jsonb { ok: false, code }) to an
* errorResponseFromCode call.
* 4. On success, refetches the per-allocation invoice/supplier_invoice rows
* to emit the same per-allocation events the legacy single-tx routes
* emit (invoice.match_confirmed, invoice.paid, supplier_invoice.*).
* Event emission is best-effort: a failure here does not roll back
* the booking; the RPC commit is the source of truth.
*/
export const POST = withRouteContext(
'transaction.match_batch',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, MatchBatchSchema, {
log,
operation: 'transaction.match_batch',
})
if (!validation.success) return validation.response
const txLog = log.child({ transactionId })
// PR #607 round 3: p_user_id removed: RPC resolves caller from
// auth.uid() directly. Keeps the attack surface off the API boundary.
// Only fakturor carry a receivable. The RPC gates on status alone, so a
// sent proforma or quote in the allocation list is refused here, as the
// single-invoice match route does.
const customerInvoiceIds = Array.from(
new Set(
validation.data.allocations.flatMap((a) =>
a.kind === 'customer_invoice' && a.invoice_id ? [a.invoice_id] : [],
),
),
)
if (customerInvoiceIds.length > 0) {
const { data: docRows, error: docError } = await supabase
.from('invoices')
.select('id, document_type')
.in('id', customerInvoiceIds)
.eq('company_id', companyId)
if (docError) {
txLog.error('match-batch: document lookup failed', docError)
return errorResponse(docError, txLog, { requestId })
}
const offender = (docRows ?? []).find((r) => r.document_type && r.document_type !== 'invoice')
if (offender) {
return errorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, {
requestId,
details: { invoiceId: offender.id, documentType: offender.document_type },
})
}
}
// Already-explained guard. A bank feed can deliver several affärshändelser
// as ONE row (a Bankgirot daily aggregate covering two customers'
// invoices), and each may already be booked on its own via "Markera som
// betald". The RPC only knows the invoices in the request: it correctly
// refuses the PAID ones, and then books the money a second time against
// whatever open invoices the user picked (the next period's identical
// ones, in the case that prompted this). The vouchers that explain the
// row are on the ledger, so refuse here and hand them back; the dialog
// links the row to them (1:N, /api/reconciliation/bank/link) instead of
// creating a new voucher. The detect + force-binding decision is the
// shared helper the MCP staging tool and the pending-operation commit
// run too (issue #2294), so the doors cannot drift. Fail-open on a
// detection error: the guard is advisory, the RPC remains the atomicity
// boundary.
const explained = await guardAlreadyExplained(supabase, companyId!, transactionId, validation.data, {
onDetectError: (err) => txLog.warn('match-batch: explaining-voucher detection failed', err as Error),
})
if (explained.status === 'blocked') {
return errorResponseFromCode('BATCH_TX_POSSIBLE_DUPLICATE', txLog, {
requestId,
details: alreadyExplainedDetails(explained),
})
}
if (explained.status === 'unverifiable') {
// force=true but the check could not run: the override cannot be
// re-verified, so it is refused rather than waved through.
return errorResponseFromCode('BATCH_TX_EXPLAINED_CHECK_FAILED', txLog, {
requestId,
details: { reason: 'detector_failed', force_rejected: true },
})
}
if (explained.status === 'overridden') {
txLog.warn('match-batch: already-explained guard bypassed', {
reason: 'force=true',
journalEntryIds: explained.set.vouchers.map((v) => v.journal_entry_id),
userId: user.id,
})
}
const { data, error } = await supabase.rpc('match_batch_allocate', {
p_tx_id: transactionId,
p_allocations: validation.data.allocations,
p_company_id: companyId,
})
if (error) {
txLog.error('match_batch_allocate RPC error', error)
return errorResponseFromCode('BATCH_RPC_FAILED', txLog, {
requestId,
details: { message: getUserErrorMessage(error) },
})
}
const result = data as RpcOk | RpcErr | null
if (!result || !result.ok) {
const code = (result as RpcErr | null)?.code ?? 'BATCH_RPC_FAILED'
const details = (result as RpcErr | null)?.details
return errorResponseFromCode(code, txLog, { requestId, details })
}
// Re-fetch the transaction row for event payloads (the RPC has already
// updated it). Lookup is non-critical: events fail open on miss.
const { data: tx } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
// Emit one event per allocation so existing subscribers (reminder
// cancellation, automation, processing-history) keep working without a
// new event channel. Loop sequentially so a single failure logs cleanly.
for (const alloc of result.allocations) {
try {
if (alloc.kind === 'customer_invoice' && alloc.invoice_id) {
const { data: invoice } = await supabase
.from('invoices')
.select('*')
.eq('id', alloc.invoice_id)
.eq('company_id', companyId)
.maybeSingle()
if (invoice && tx) {
await eventBus.emit({
type: 'invoice.match_confirmed',
payload: {
invoice: invoice as Invoice,
transaction: tx as Transaction,
userId: user.id,
companyId,
},
})
}
} else if (alloc.kind === 'supplier_invoice' && alloc.supplier_invoice_id) {
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('*')
.eq('id', alloc.supplier_invoice_id)
.eq('company_id', companyId)
.maybeSingle()
if (supplierInvoice && tx) {
await eventBus.emit({
type: 'supplier_invoice.match_confirmed',
payload: {
supplierInvoice: supplierInvoice as SupplierInvoice,
transaction: tx as Transaction,
userId: user.id,
companyId,
},
})
}
}
} catch (err) {
txLog.warn('match_batch event emission failed', err as Error)
}
}
// Every allocation the RPC settled in full retires its suggestion pointer
// from the company's OTHER transactions (issue #1259). This request's own
// row is linked by the RPC, so it is excluded there. Shared with the MCP
// executor for the same RPC (commitMatchBatchAllocate).
await clearSettledBatchAllocationSuggestions(
supabase,
companyId!,
result.allocations,
transactionId,
)
// The override was acted on: leave the durable behandlingshistorik
// record (same event the categorize guard writes), never just a log line.
if (explained.status === 'overridden') {
await recordExplainedOverride(
companyId!,
transactionId,
explained.set,
{ actor: { type: 'user', id: user.id }, via: 'dashboard_force' },
(err) => txLog.warn('match-batch: failed to record override behandlingshistorik', err as Error),
)
}
return NextResponse.json({
data: {
journal_entry_id: result.journal_entry_id,
voucher_series: result.voucher_series,
voucher_number: result.voucher_number,
allocations: result.allocations,
total_allocated: result.total_allocated,
leftover: result.leftover,
},
})
},
{ requireWrite: true },
)