cce0de5704
* 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>
262 lines
9.9 KiB
TypeScript
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 },
|
|
)
|