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>
This commit is contained in:
Jakob Wennberg
2026-09-06 18:56:36 +02:00
committed by GitHub
parent 39d409d257
commit cce0de5704
17 changed files with 1970 additions and 40 deletions
+42
View File
@@ -818,6 +818,48 @@ processing_activities:
- company_name_header_injection_sanitized
- cron_secret_authenticated_trigger
- id: bookkeeping.duplicate_dismissal_history
name: Behandlingshistorik för åsidosatt dubblettkontroll
purpose: >-
Bevara en oföränderlig post i behandlingshistoriken (processing_history,
händelsetyp BankTransactionDuplicateDismissed) när en användare eller en
API-nyckel bokför en banktransaktion trots att dubblettkontrollen visade
att bokförda verifikat redan förklarar den (force=true i dashboarden,
i MCP-verktyget eller vid godkännande av en väntande operation). Posten
gör beslutet rekonstruerbart för revisor och Skatteverket: vilka
verifikat som åsidosattes, belopp, konto, datum och vem som fattade
beslutet. Innehåller endast interna id:n, verifikationsnummer, belopp
och datum; aldrig motpartsnamn, transaktionsbeskrivningar eller
personnummer.
lawful_basis: art_6_1_c # BFL 5 kap. 11 § + BFNAR 2013:2 p. 9.16 (behandlingshistorik)
special_category_basis: null
controller: gnubok-tenant
processor: supabase
data_subjects:
- company_member # the user (or API-key owner) who honoured the override
data_categories:
- user.activity_timestamp
- user.financial.transaction # amount + settlement account of the bank row, no counterparty
recipients:
- name: Supabase
country: EU
role: processor
international_transfers:
applicable: false
mechanism: null
retention:
duration: through_seventh_calendar_year_after_fiscal_year_end
basis: bfl_7_kap_and_gdpr_storage_limitation
stored_in:
- processing_history
security_measures:
- rls_company_scoped
- service_role_insert_only_no_update_or_delete_triggers
- personnummer_pattern_rejected_at_append
- ids_labels_amounts_dates_only_never_descriptions_or_counterparties
- actor_recorded_as_pseudonymous_user_or_api_key_id
- written_only_after_the_booking_succeeded
- id: documents.own_company_supplier_guard
name: Egenbolagskontroll vid dokumentextraktion
purpose: >-
+10
View File
@@ -1610,6 +1610,16 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Utlägg phase 2, repayment from the bank line: create_expense_payout_batch takes an optional bank transaction and, under the same row locks, requires an unbooked SEK outflow equal to the claims' total and stamps it (journal_entry_id, is_business, reconciliation_method) in the transaction that posts the verifikat and flips the claims to paid. The pairing bank line ↔ person is computed at read time from the open claims (exact öre match on one person's total, ambiguous totals skipped), surfaced as a suggested match on Hem and as the inbox row's primary button, instead of a new hint column on transactions: the candidate pool is the handful of open claims, so a column written at ingest would add schema and a stale-pointer class for no saving. Old 6-parameter RPC signature dropped in the same migration so a 6-argument call cannot become ambiguous. "Betala ut" on /expenses stays for companies paying from an account without a feed. "Via lön" and the supplier-form control are split into their own issues rather than stretched into this PR.
[2026-09-06] Utlägg gaps closed on the phase 2 PR: (1) a manual "Matcha mot utlägg" picker in the inbox row menu for outflows the exact-amount pairing missed, offered only while the company has open claims; the sum of picked receipts must equal the row to the öre and the same RPC books it, so a partial receipt can never be marked paid (partial payout inside the RPC was considered and left out: it changes money logic for a rare case, rounding a transfer up is not solved). (2) A foreign receipt (currency other than SEK) defaults VAT to 0 in the Underlag dialog with a note: foreign VAT is not deductible on 2641, and the wizard's seller-country step is gone from this path. (3) Enskild firma: a claim on 2018 is egen insättning, not a debt; it is excluded from Att göra, the attention resource, suggestions and the picker, and a payout for it debits 2013 (eget uttag) instead of 2018.
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
[2026-09-06] Fiscal-year edit dialog (#2287, PR #2337) lets the name follow typed dates only while the existing name has fiscalYearName()'s shape (isDerivedFiscalYearName), not only when it equals the name for the current dates: the customer row's "Räkenskapsår 2027" never matched its dates and would otherwise have stayed wrong after the dates were fixed; a hand-written name is never overwritten. FiscalPeriodEditor (Settings > Företag, first year only, i18n-keyed name that writes "Fiscal year N" in the EN locale) is now a strict subset of Ändra and was left in place as a founder call because removing it is user-visible.
[2026-09-06] Article company-scope for invoice lines (#2059 part 2, PR #2339) enforced once in buildInvoiceWriteData (covers cookie, v1, webshop, sales-order and MCP-update writers) rather than as a DB constraint: invoice_items has no company_id and articles no UNIQUE (id, company_id), so a composite FK needs a denormalized column + backfill + sync trigger and a constraint trigger cannot be validated against prod rows the session could not query; DB enforcement proposed as option B for after Part 1 rebases commitCreateInvoice onto the builder.
[2026-09-06] Migrated invoice rows (#2313, PR #2340) are written only through complete_invoice_rows (FOR UPDATE on the invoice, insert iff no rows, header in the same transaction), shared by the completion pass and the wizard; the unique index on invoice_items (invoice_id, sort_order) is deferred: it needs a renumbering of legacy rows under posted invoices first and is the weaker invariant anyway (two writers with different sort_order sets both pass it while still doubling the invoice).
[2026-09-06] Completion cron (#2309, PR #2341) sizes registers with a PostgREST embed-null HEAD count (invoice_items=is.null) and orders smallest first, no stored state: the pass's own candidate load is a full register read, not a count; the new grammar is proven by a tool-pg test because a count that silently returned 0 would switch the cron off; a persisted fairness counter for the reverse-starvation case was rejected as state for a regime prod is not in (57 consents in 60 days), the deferred list in every run summary is the signal instead. The phantom-column schema guard now recognises the embed-null anti-join (is on a name the same chain embedded) instead of an allowlist entry.
[2026-09-06] Guided/manual SIE mapping step (#2212, PR #2342) renders "created on import" as the target for any in-range source account the chart lacks (incl. nameless ones), instead of adding source accounts to the target list or blocking: the import engine already created them correctly; the defect was the step hiding it, and the shared-component fix covers both wizards.
[2026-09-06] Assistant surfaces (#2204, PR #2343) gate on one client flag (CompanyContext.assistantAvailable from getAiStatus) and hide, the FAB falls back to general.help, no global disable switch: the capability flag already says "cannot run", a second switch is a state nobody asked for, and a disabled control the user cannot enable is noise on a self-host.
[2026-09-06] Migrated-paid invoices (#2213, PR #2344) are detected from data (settled status, zero invoice_payments rows, no posted invoice_paid/invoice_cash_payment voucher keyed on the invoice), not a provenance column: every Accounted settlement path writes the sub-ledger row fail-closed, so the state itself is the marker; external_invoice_number is the self-billing field, not a migration marker.
[2026-09-06] Duplicate-payment needle (#2299, PR #2345) = first non-legal-form token of >= 2 chars (not >= 3 as the issue proposed), one .or() probe on merchant_name+description, one detector shared by supplier and customer mark-paid: two-letter first tokens are initialisms banks keep verbatim (SJ, 3M, DB), and the alnum-only needle is what makes the .or() DSL interpolation safe.
[2026-09-06] MCP already-explained guard (#2294, PR #2346) refuses to stage instead of staging an op with a blocker in its preview: a staged-but-blocked op could still be approved, would auto-reject at commit and would pollute /pending; a coded refusal carrying the voucher set, the link call and the exact force binding gives the agent the same steer with no new op state, and commit re-runs the same shared guard as the last gate.
[2026-09-06] Invoice-to-verifikat links (#2298, PR #2347) are resolved by readers from the invoice side (invoices.journal_entry_id / invoice_payments.journal_entry_id) instead of being written onto the posted entry at link time: writing source_type/source_id to a posted verifikat is outside the rättelse paths (Hard Rule 1) and would erase the SIE-import provenance, so the missing-underlag RPCs, their TS mirrors and the periodisk sammanställning all follow the link; the PS report also stopped filtering on source_type, which had silently dropped kontantmetod (invoice_cash_payment) sales.
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.
@@ -37,6 +37,13 @@ vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectExplainingVoucherSetForTransaction: mockDetectExplaining,
}))
// An honoured force override is written to behandlingshistorik after the RPC
// succeeds (issue #2294). Mocked so it never touches a service client here.
const { mockAppendProcessingHistory } = vi.hoisted(() => ({ mockAppendProcessingHistory: vi.fn() }))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: mockAppendProcessingHistory,
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
@@ -350,6 +357,17 @@ describe('POST /api/transactions/[id]/match-batch: already-explained guard', ()
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
expect(response.status).toBe(200)
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
// Never silent: the honoured override leaves a behandlingshistorik
// record naming the vouchers it booked over (issue #2294).
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory.mock.calls[0][0]).toMatchObject({
companyId: 'company-1',
aggregateType: 'BankTransaction',
aggregateId: TX_UUID,
eventType: 'BankTransactionDuplicateDismissed',
actor: { type: 'user', id: 'user-1' },
payload: { dismissed_journal_entry_ids: [JE_A, JE_B], via: 'dashboard_force' },
})
})
it('refuses force=true whose ids do not match the set it re-detects', async () => {
@@ -395,4 +413,27 @@ describe('POST /api/transactions/[id]/match-batch: already-explained guard', ()
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
expect(response.status).toBe(200)
})
it('refuses force=true when the detector throws: an override that cannot be re-verified is never honoured', async () => {
mockDetectExplaining.mockRejectedValue(new Error('ledger scan timed out'))
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
method: 'POST',
body: {
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }],
force: true,
expected_journal_entry_ids: [JE_A, JE_B],
},
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string; force_rejected: boolean } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('BATCH_TX_EXPLAINED_CHECK_FAILED')
expect(body.error.details).toEqual({ reason: 'detector_failed', force_rejected: true })
expect(mockSupabase.rpc).not.toHaveBeenCalled()
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
})
+40 -29
View File
@@ -5,7 +5,11 @@ 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 { detectExplainingVoucherSetForTransaction } from '@/lib/invoices/duplicate-payment-detection'
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'
@@ -112,37 +116,32 @@ export const POST = withRouteContext(
// 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. Fail-open on a detection error: the guard is
// advisory, the RPC remains the atomicity boundary.
let explaining: Awaited<ReturnType<typeof detectExplainingVoucherSetForTransaction>> = null
try {
explaining = await detectExplainingVoucherSetForTransaction(supabase, companyId!, transactionId)
} catch (err) {
txLog.warn('match-batch: explaining-voucher detection failed', err as Error)
// 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 (explaining) {
const detectedIds = explaining.vouchers.map((v) => v.journal_entry_id).sort()
const expectedIds = [...(validation.data.expected_journal_entry_ids ?? [])].sort()
const acknowledged =
validation.data.force === true &&
detectedIds.length === expectedIds.length &&
detectedIds.every((id, i) => id === expectedIds[i])
if (!acknowledged) {
return errorResponseFromCode('BATCH_TX_POSSIBLE_DUPLICATE', txLog, {
requestId,
details: {
vouchers: explaining.vouchers,
total: explaining.total,
bank_account_number: explaining.bank_account_number,
same_date: explaining.same_date,
// force=true with a stale or missing set: the caller must re-read.
force_rejected: validation.data.force === true,
},
})
}
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: detectedIds,
journalEntryIds: explained.set.vouchers.map((v) => v.journal_entry_id),
userId: user.id,
})
}
@@ -235,6 +234,18 @@ export const POST = withRouteContext(
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,
@@ -1,6 +1,7 @@
'use client'
import { ArrowDown } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { formatCurrency, formatDate } from '@/lib/utils'
interface MatchTransactionInvoicePreviewProps {
@@ -22,6 +23,14 @@ export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoice
const invoiceCurrency = (data.invoice_currency as string) || txCurrency
const invoiceDate = data.invoice_date as string | undefined
const customerName = data.customer_name as string | undefined
// Staged by the MCP tool when the duplicate check could not run, or when
// force=true books over a voucher that already looks like this payment
// (issue #2294). The server owns the wording; rendered verbatim so the
// /pending card and the agent read the same warning.
const complianceWarning =
typeof data.compliance_warning === 'string' && data.compliance_warning.trim()
? data.compliance_warning
: null
// BFL 5 kap 4§ requires bookings to be made "so soon as possible" relative
// to the affärshändelse, so a transaction and invoice that diverge by more
@@ -35,6 +44,7 @@ export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoice
return (
<div className="space-y-3 text-sm">
{complianceWarning && <AttnLine>{complianceWarning}</AttnLine>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PreviewCard label="Transaktion">
{txDate && <Row label="Datum" value={formatDate(txDate)} tabular />}
@@ -0,0 +1,217 @@
/**
* gnubok_match_batch_allocate: the already-explained guard at stage time
* (issue #2294).
*
* The dashboard door refuses a batch allocation with
* BATCH_TX_POSSIBLE_DUPLICATE when posted, unlinked vouchers on the row's
* settlement account sum exactly to the row (gecko's Bankgirot aggregate, PR
* #2300). The MCP door used to stage straight past that, so an agent could
* book the aggregate a second time. Now the same detector runs before
* staging: the refusal names the vouchers and the link call that resolves
* the row, and force is bound to exactly those ids.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockDetectSet } = vi.hoisted(() => ({ mockDetectSet: vi.fn() }))
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectExplainingVoucherSetForTransaction: mockDetectSet,
detectDuplicatePaymentVoucher: vi.fn(async () => null),
}))
import { tools } from '../server'
const allocate = tools.find((t) => t.name === 'gnubok_match_batch_allocate')!
const TX_ID = '11111111-1111-4111-8111-111111111111'
const INV_ID = '22222222-2222-4222-8222-222222222222'
const JE_A = '55555555-5555-4555-8555-555555555555'
const JE_B = '66666666-6666-4666-8666-666666666666'
const txRow = {
id: TX_ID,
description: 'BGGIRERING',
merchant_name: null,
amount: 88250,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
cash_account_id: 'ca-1',
date: '2026-07-31',
journal_entry_id: null,
}
const explainingSet = {
vouchers: [
{ journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
{ journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
],
total: 88250,
bank_account_number: '1930',
same_date: true,
}
const args = (extra: Record<string, unknown> = {}) => ({
transaction_id: TX_ID,
allocations: [{ kind: 'customer_invoice', invoice_id: INV_ID, amount: 88250 }],
...extra,
})
function run(supabase: unknown, extra: Record<string, unknown> = {}) {
return allocate.execute(args(extra), 'company-1', 'user-1', supabase as never, { type: 'api_key' } as never)
}
/** tx fetch + invoice tenant pre-check: everything the tool reads before the guard. */
function enqueuePreGuard(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: txRow, error: null })
enqueue({ data: [{ id: INV_ID, document_type: 'invoice' }], error: null })
}
/** period_status lookups + the pending_operations insert. */
function enqueueStage(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: { bookkeeping_locked_through: null }, error: null }) // company_settings
enqueue({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }) // fiscal_periods
enqueue({ data: { id: 'op-batch-1' }, error: null }) // pending_operations insert
}
beforeEach(() => {
vi.clearAllMocks()
mockDetectSet.mockResolvedValue(null)
})
describe('gnubok_match_batch_allocate: already-explained guard at stage time', () => {
it('refuses to stage, coded BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the link call and the force binding', async () => {
mockDetectSet.mockResolvedValue(explainingSet)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
const err = await run(supabase).then(() => null, (e: Error & { code?: string }) => e)
expect(err).toBeInstanceOf(Error)
expect(err!.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE')
expect(err!.message).toContain('A57 + A58')
expect(err!.message).toContain('gnubok_reconcile_match')
expect(err!.message).toContain('bank:ca-1')
expect(err!.message).toContain(`expected_journal_entry_ids=${JSON.stringify([JE_A, JE_B])}`)
// The row the tool already holds goes to the detector: no second fetch.
expect(mockDetectSet).toHaveBeenCalledWith(supabase, 'company-1', expect.objectContaining({ id: TX_ID, cash_account_id: 'ca-1' }))
// Nothing was staged.
const tables = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(tables).not.toContain('pending_operations')
})
it('points a single explaining voucher at the 1:1 link tool too', async () => {
mockDetectSet.mockResolvedValue({ ...explainingSet, vouchers: [explainingSet.vouchers[0]] })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
await expect(run(supabase)).rejects.toThrow(/gnubok_link_transaction_to_journal_entry/)
})
it('stages with a compliance warning when force names exactly the detected set, and persists the binding', async () => {
mockDetectSet.mockResolvedValue(explainingSet)
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueueStage(enqueue)
const result = (await run(supabase, { force: true, expected_journal_entry_ids: [JE_B, JE_A] })) as {
staged: boolean
operation_id?: string
message: string
preview: Record<string, unknown>
}
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-batch-1')
expect(result.preview.compliance_warning).toContain('A57 + A58')
expect(result.message).toContain('WARNING')
// The commit executor re-validates against the set detected at commit,
// so the binding must travel with the op.
const inserted = findCall('pending_operations', 'insert')?.[0] as { params: Record<string, unknown> }
expect(inserted.params).toMatchObject({
transaction_id: TX_ID,
force: true,
expected_journal_entry_ids: [JE_B, JE_A],
})
})
it('refuses force whose ids are not exactly the set detected now', async () => {
mockDetectSet.mockResolvedValue(explainingSet)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
const err = await run(supabase, { force: true, expected_journal_entry_ids: [JE_A] }).then(
() => null,
(e: Error & { code?: string }) => e,
)
expect(err!.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE')
expect(err!.message).toMatch(/^force=true avvisad/)
const tables = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(tables).not.toContain('pending_operations')
})
it('rejects force without expected_journal_entry_ids before any query runs', async () => {
const { supabase } = createQueuedMockSupabase()
const err = await run(supabase, { force: true }).then(() => null, (e: Error & { code?: string }) => e)
expect(err!.code).toBe('VALIDATION_ERROR')
expect(err!.message).toContain('expected_journal_entry_ids is required when force=true')
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects a malformed expected_journal_entry_ids at the boundary instead of silently filtering it', async () => {
// No host validates inputSchema at runtime: a string, an empty array, a
// non-string element and more than 10 ids are each a validation error,
// never a filtered list that then reads as a force mismatch.
for (const bad of [JE_A, [], [JE_A, 42], [JE_A, ''], Array.from({ length: 11 }, () => JE_A)]) {
const { supabase } = createQueuedMockSupabase()
const err = await run(supabase, { force: true, expected_journal_entry_ids: bad }).then(
() => null,
(e: Error & { code?: string }) => e,
)
expect(err?.code, JSON.stringify(bad)).toBe('VALIDATION_ERROR')
expect(err!.message).toContain('array of 1 to 10 journal_entry_id strings')
expect(supabase.from).not.toHaveBeenCalled()
}
})
it('does not persist a binding on a plain stage', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueueStage(enqueue)
const result = (await run(supabase)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.compliance_warning).toBeUndefined()
const inserted = findCall('pending_operations', 'insert')?.[0] as { params: Record<string, unknown> }
expect(inserted.params).not.toHaveProperty('force')
expect(inserted.params).not.toHaveProperty('expected_journal_entry_ids')
})
it('fails open when the detector throws without force, but the approval card says the check did not run', async () => {
mockDetectSet.mockRejectedValue(new Error('ledger scan timed out'))
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueueStage(enqueue)
const result = (await run(supabase)) as { staged: boolean; message: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.compliance_warning).toContain('Dubblettkontrollen kunde inte köras')
expect(result.message).toContain('WARNING')
// Persisted with the op, so /pending (GenericPreview) renders the same warning.
const inserted = findCall('pending_operations', 'insert')?.[0] as { preview_data: Record<string, unknown> }
expect(inserted.preview_data.compliance_warning).toContain('Dubblettkontrollen kunde inte köras')
})
it('refuses force=true when the detector throws: an override that cannot be re-verified is never staged', async () => {
mockDetectSet.mockRejectedValue(new Error('ledger scan timed out'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
const err = await run(supabase, { force: true, expected_journal_entry_ids: [JE_A, JE_B] }).then(
() => null,
(e: Error & { code?: string }) => e,
)
expect(err!.code).toBe('BATCH_TX_EXPLAINED_CHECK_FAILED')
const tables = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(tables).not.toContain('pending_operations')
})
})
@@ -0,0 +1,207 @@
/**
* gnubok_match_transaction_to_invoice: the soft-duplicate guard at stage time
* (issue #2294, parity with the dashboard match-invoice route's
* MATCH_INVOICE_POSSIBLE_DUPLICATE / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH).
*
* A manual verifikation that already books the receipt is surfaced before
* anything is staged, with the voucher named and the link call that resolves
* it; force is bound to that exact candidate and travels with the op so the
* commit executor can re-validate it.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockDetectCandidate } = vi.hoisted(() => ({ mockDetectCandidate: vi.fn() }))
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectDuplicatePaymentVoucher: mockDetectCandidate,
detectExplainingVoucherSetForTransaction: vi.fn(async () => null),
}))
import { tools } from '../server'
const match = tools.find((t) => t.name === 'gnubok_match_transaction_to_invoice')!
const TX_ID = '11111111-1111-4111-8111-111111111111'
const INV_ID = '22222222-2222-4222-8222-222222222222'
const JE_MANUAL = '55555555-5555-4555-8555-555555555555'
const JE_OTHER = '66666666-6666-4666-8666-666666666666'
const txRow = {
id: TX_ID,
description: 'SWISH INBET',
merchant_name: null,
amount: 1000,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
date: '2026-05-15',
invoice_id: null,
}
const invoiceRow = {
id: INV_ID,
invoice_number: 'F-2026001',
status: 'sent',
document_type: 'invoice',
total: 1000,
currency: 'SEK',
invoice_date: '2026-05-01',
customer: { name: 'Kund AB' },
}
const candidate = {
journal_entry_id: JE_MANUAL,
voucher_label: 'A12',
entry_date: '2026-05-15',
description: 'Inbetalning faktura',
amount: 1000,
bank_account_number: '1930',
reason: 'exact_amount_same_date',
amount_verified: true,
unverified_reason: null,
}
function run(supabase: unknown, extra: Record<string, unknown> = {}) {
return match.execute(
{ transaction_id: TX_ID, invoice_id: INV_ID, ...extra },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' } as never,
)
}
function enqueuePreGuard(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: txRow, error: null })
enqueue({ data: invoiceRow, error: null })
}
function enqueueStage(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: { bookkeeping_locked_through: null }, error: null }) // company_settings
enqueue({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }) // fiscal_periods
enqueue({ data: { id: 'op-match-1' }, error: null }) // pending_operations insert
}
beforeEach(() => {
vi.clearAllMocks()
mockDetectCandidate.mockResolvedValue(null)
})
describe('gnubok_match_transaction_to_invoice: soft-duplicate guard at stage time', () => {
it('refuses to stage, coded MATCH_INVOICE_POSSIBLE_DUPLICATE, naming the voucher, the link tool and the force binding', async () => {
mockDetectCandidate.mockResolvedValue(candidate)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
const err = await run(supabase).then(() => null, (e: Error & { code?: string }) => e)
expect(err).toBeInstanceOf(Error)
expect(err!.code).toBe('MATCH_INVOICE_POSSIBLE_DUPLICATE')
expect(err!.message).toContain('verifikat A12')
expect(err!.message).toContain('gnubok_link_transaction_to_journal_entry')
expect(err!.message).toContain(`expected_journal_entry_id="${JE_MANUAL}"`)
expect(mockDetectCandidate).toHaveBeenCalledWith(
supabase,
expect.objectContaining({ companyId: 'company-1', transactionId: TX_ID, transactionAmount: 1000, transactionCurrency: 'SEK' }),
)
const tables = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(tables).not.toContain('pending_operations')
})
it('stages with a compliance warning when force echoes the detected candidate, and persists the binding', async () => {
mockDetectCandidate.mockResolvedValue(candidate)
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueueStage(enqueue)
const result = (await run(supabase, { force: true, expected_journal_entry_id: JE_MANUAL })) as {
staged: boolean
operation_id?: string
preview: Record<string, unknown>
}
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-match-1')
expect(result.preview.compliance_warning).toContain('A12')
const inserted = findCall('pending_operations', 'insert')?.[0] as { params: Record<string, unknown> }
expect(inserted.params).toMatchObject({
transaction_id: TX_ID,
invoice_id: INV_ID,
force: true,
expected_journal_entry_id: JE_MANUAL,
})
})
it('refuses a stale force id as MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH', async () => {
mockDetectCandidate.mockResolvedValue(candidate)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
const err = await run(supabase, { force: true, expected_journal_entry_id: JE_OTHER }).then(
() => null,
(e: Error & { code?: string }) => e,
)
expect(err!.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH')
expect(err!.message).toContain(JE_OTHER)
expect(err!.message).toContain(JE_MANUAL)
})
it('refuses force when no candidate is detected any more (force is moot)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
await expect(run(supabase, { force: true, expected_journal_entry_id: JE_MANUAL })).rejects.toMatchObject({
code: 'MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH',
})
})
it('rejects force without expected_journal_entry_id before any query runs', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(run(supabase, { force: true })).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects a non-string expected_journal_entry_id at the boundary instead of silently dropping it', async () => {
for (const bad of [42, '', [JE_MANUAL]]) {
const { supabase } = createQueuedMockSupabase()
await expect(run(supabase, { expected_journal_entry_id: bad })).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
expect(supabase.from).not.toHaveBeenCalled()
}
})
it('fails open when the detector throws without force, but the approval card says the check did not run', async () => {
mockDetectCandidate.mockRejectedValue(new Error('scan failed'))
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueueStage(enqueue)
const result = (await run(supabase)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.compliance_warning).toContain('Dubblettkontrollen kunde inte köras')
// Persisted with the op: MatchTransactionInvoicePreview renders it on /pending.
const inserted = findCall('pending_operations', 'insert')?.[0] as { preview_data: Record<string, unknown> }
expect(inserted.preview_data.compliance_warning).toContain('Dubblettkontrollen kunde inte köras')
})
it('refuses force=true when the detector throws: an override that cannot be re-verified is never staged', async () => {
mockDetectCandidate.mockRejectedValue(new Error('scan failed'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
await expect(run(supabase, { force: true, expected_journal_entry_id: JE_MANUAL })).rejects.toMatchObject({
code: 'MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH',
})
const tables = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(tables).not.toContain('pending_operations')
})
it('stages a clean match without a binding or a warning', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueueStage(enqueue)
const result = (await run(supabase)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.compliance_warning).toBeUndefined()
const inserted = findCall('pending_operations', 'insert')?.[0] as { params: Record<string, unknown> }
expect(inserted.params).toEqual({ transaction_id: TX_ID, invoice_id: INV_ID })
})
})
@@ -390,6 +390,20 @@ describe('tools/list payload size guard', () => {
// description, no example; the lookup itself now goes through the
// shared resolver with the cache, so the case is also rarer.
//
// * Held at 60K with the force binding on the two match tools
// (2026-09-06, #2294): force + expected_journal_entry_ids on
// gnubok_match_batch_allocate and force + expected_journal_entry_id on
// gnubok_match_transaction_to_invoice, the override an agent needs
// once the already-explained guard refuses at stage time (the refusal
// message carries the exact ids to echo, so the property descriptions
// are one clause each). ~100 tokens against 93 of headroom, paid for
// inside the same two tools: the two "UUID of ..." property
// descriptions on the single tool dropped (names are the contract, as
// on the batch tool), and the batch tool's "Use when ..." sentence
// folded into its first sentence. Measured 59 988 on the accounted
// projection after the trims; headroom is 12, so the next addition
// demotes a read first.
//
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
// can only invoke what tools/list showed them, which is the constraint that
+226 -11
View File
@@ -272,6 +272,13 @@ import { getSuggestedCategories, buildMerchantHistory, merchantHistoryFor } from
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { buildDuplicateBookingClaim } from '@/lib/transactions/categorize-core'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import {
describeExplainingSet,
guardAlreadyExplained,
guardDuplicatePaymentVoucher,
type AlreadyExplainedOutcome,
type DuplicateCandidateOutcome,
} from '@/lib/invoices/already-explained-guard'
import { getEmailService } from '@/lib/email/service'
import { hasCapability, capabilityBlockedError } from '@/lib/entitlements/has-capability'
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
@@ -558,6 +565,79 @@ function registryError(code: string): Error {
return Object.assign(new Error(getErrorEntry(code)?.message_sv ?? code), { code })
}
/**
* The already-explained refusal for gnubok_match_batch_allocate, coded
* BATCH_TX_POSSIBLE_DUPLICATE (same code the dashboard route answers with).
* The MCP error envelope carries no `details`, so the vouchers, the link
* call that resolves the row and the exact force binding are all in the
* message: that is what the agent reads. One voucher is also linkable
* through gnubok_link_transaction_to_journal_entry (which can settle a
* kundfaktura at the same time); several need the bank 1:N pair on
* gnubok_reconcile_match.
*/
function alreadyExplainedRefusal(
outcome: Extract<AlreadyExplainedOutcome, { status: 'blocked' }>,
transactionId: string,
cashAccountId: string | null,
): Error {
const { set } = outcome
const ids = set.vouchers.map((v) => v.journal_entry_id)
const accountKey = `bank:${cashAccountId ?? '<cash_account_id>'}`
const link =
ids.length === 1
? `gnubok_link_transaction_to_journal_entry (transaction_id="${transactionId}", journal_entry_id="${ids[0]}", invoice_id om en kundfaktura ska markeras betald samtidigt) ` +
`eller gnubok_reconcile_match (account_key "${accountKey}", pairs [{ external_ids: ["${transactionId}"], journal_entry_ids: ["${ids[0]}"] }])`
: `gnubok_reconcile_match (account_key "${accountKey}", pairs [{ external_ids: ["${transactionId}"], journal_entry_ids: ${JSON.stringify(ids)}, allocations: [{ journal_entry_id, amount }] per verifikat, summan = radens belopp })`
const message = outcome.force_rejected
? `force=true avvisad: expected_journal_entry_ids är inte exakt de verifikat som förklarar raden just nu (${describeExplainingSet(set)}). ` +
`Koppla raden till dem i stället: ${link}. Om raden verkligen är en separat affärshändelse: anropa igen med force=true och expected_journal_entry_ids=${JSON.stringify(ids)}.`
: `Transaktionen ser redan ut att vara bokförd som ${describeExplainingSet(set)}: bokförda verifikat utan bankkoppling på kontot summerar exakt till beloppet. ` +
`Bokför inte igen; koppla raden till dem: ${link}. Endast om raden verkligen är en separat affärshändelse: anropa igen med force=true och expected_journal_entry_ids=${JSON.stringify(ids)}.`
return Object.assign(new Error(message), { code: 'BATCH_TX_POSSIBLE_DUPLICATE' })
}
/**
* Staged into preview.compliance_warning when a duplicate / already-explained
* check threw at stage time without force. The stage fails open (the commit
* executor re-runs the check as the hard gate), but the approver must see
* that the check did not run rather than approve as if it had.
*/
const DUPLICATE_CHECK_FAILED_NOTE =
'Dubblettkontrollen kunde inte köras vid stagingen: kontrollera manuellt att raden inte redan är bokförd ' +
'(verifikat utan bankkoppling på kontot) innan du godkänner. Kontrollen körs igen vid godkännandet.'
/**
* The soft-duplicate refusal for gnubok_match_transaction_to_invoice, coded
* like the dashboard route: MATCH_INVOICE_POSSIBLE_DUPLICATE without force,
* MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when force names a stale or absent
* candidate.
*/
function duplicateCandidateRefusal(
outcome: Extract<DuplicateCandidateOutcome, { status: 'blocked' | 'mismatch' }>,
): Error {
if (outcome.status === 'mismatch') {
return Object.assign(
new Error(
`force=true avvisad: expected_journal_entry_id ${outcome.expected_journal_entry_id ?? '(saknas)'} är inte den verifikation dubblettkontrollen hittar just nu ` +
`(${outcome.detected_journal_entry_id ?? 'ingen dubblett hittad: anropa igen utan force'}).`,
),
{ code: 'MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH' },
)
}
const c = outcome.candidate
const amount = c.amount_verified
? `${c.amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} kr`
: 'belopp ej verifierat: raden saknar SEK-värde'
return Object.assign(
new Error(
`Möjlig dubblettbokföring: verifikat ${c.voucher_label} (${c.entry_date}, konto ${c.bank_account_number}, ${amount}) ser redan ut att bokföra den här betalningen. ` +
`Koppla transaktionen till den i stället: gnubok_link_transaction_to_journal_entry (journal_entry_id="${c.journal_entry_id}", invoice_id för att markera fakturan betald samtidigt). ` +
`Endast om det verkligen är en separat betalning: anropa igen med force=true och expected_journal_entry_id="${c.journal_entry_id}".`,
),
{ code: 'MATCH_INVOICE_POSSIBLE_DUPLICATE' },
)
}
interface McpTool {
name: string
// Top-level Tool.title per MCP spec 2025-06-18 (human-facing label for
@@ -10708,13 +10788,15 @@ export const tools: McpTool[] = [
name: 'gnubok_match_transaction_to_invoice',
keywords: ['matcha betalning', 'kundfaktura', 'inbetalning'],
title: 'Match Transaction to Invoice',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer before staging. Supports partial payments and auto-storno of prior categorization.',
description: 'Match 1 bank tx (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number first. Partial payments and auto-storno of a prior categorization supported. Stages.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_id: { type: 'string', description: 'UUID of the bank transaction' },
invoice_id: { type: 'string', description: 'UUID of the invoice to match' },
transaction_id: { type: 'string' },
invoice_id: { type: 'string' },
force: { type: 'boolean', description: 'Override a MATCH_INVOICE_POSSIBLE_DUPLICATE refusal.' },
expected_journal_entry_id: { type: 'string', description: 'With force: the id the refusal named.' },
},
required: ['transaction_id', 'invoice_id'],
},
@@ -10724,11 +10806,26 @@ export const tools: McpTool[] = [
const transactionId = args.transaction_id as string
const invoiceId = args.invoice_id as string
if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required')
// No host validates inputSchema at runtime: check the override shape
// here so a malformed binding is a validation error, never a silently
// dropped one that then reads as a force mismatch.
const force = args.force === true
if (
args.expected_journal_entry_id !== undefined &&
(typeof args.expected_journal_entry_id !== 'string' || !args.expected_journal_entry_id)
) {
throw codedError('VALIDATION_ERROR', 'expected_journal_entry_id must be a journal_entry_id string')
}
const expectedJournalEntryId = args.expected_journal_entry_id as string | undefined
if (force && !expectedJournalEntryId) {
throw codedError('VALIDATION_ERROR', 'expected_journal_entry_id is required when force=true')
}
// Validate both exist and are matchable
// Validate both exist and are matchable. amount_sek / exchange_rate feed
// the duplicate guard: it compares this bank line against SEK ledger legs.
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, invoice_id')
.select('id, description, merchant_name, amount, currency, amount_sek, exchange_rate, date, invoice_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
@@ -10754,11 +10851,44 @@ export const tools: McpTool[] = [
throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)')
}
// Soft-duplicate guard at stage time (parity with the dashboard match
// route's MATCH_INVOICE_POSSIBLE_DUPLICATE): a manual verifikation that
// already books this receipt is surfaced NOW, so the agent links the
// row to it instead of queuing a second voucher for approval. The
// commit executor re-runs the same guard as the hard gate, re-binding
// force to the candidate detected then (issue #2294).
// A detector failure without force fails open, but never silently:
// the approval card carries the warning (preview.compliance_warning)
// so the approver knows the check did not run. With force the guard
// returns a mismatch, refused below.
let duplicateCheckFailed = false
const duplicate = await guardDuplicatePaymentVoucher(
supabase,
companyId,
transaction,
{ force, expected_journal_entry_id: expectedJournalEntryId },
{
onDetectError: (err) => {
duplicateCheckFailed = true
log.warn('match_transaction_to_invoice: duplicate detection failed (continuing)', { error: err instanceof Error ? err.message : String(err) })
},
},
)
if (duplicate.status === 'blocked' || duplicate.status === 'mismatch') {
throw duplicateCandidateRefusal(duplicate)
}
const txDesc = transaction.merchant_name || transaction.description || transactionId
return stagePendingOperation(supabase, companyId, userId, 'match_transaction_invoice',
`Matcha: ${txDesc}${invoice.invoice_number}`,
{ transaction_id: transactionId, invoice_id: invoiceId },
{
transaction_id: transactionId,
invoice_id: invoiceId,
// The binding travels with the op so the commit executor can
// re-validate it against the candidate detected at commit time.
...(force ? { force: true, expected_journal_entry_id: expectedJournalEntryId } : {}),
},
{
transaction_description: txDesc,
transaction_amount: transaction.amount,
@@ -10777,7 +10907,20 @@ export const tools: McpTool[] = [
{
description: 'After approval the transaction is linked and the invoice is marked paid. Use gnubok_get_ar_ledger to verify the customer balance.',
tool: 'gnubok_get_ar_ledger',
}
},
{
dateForPeriodCheck: transaction.date,
// Never silent: an honoured override names the voucher it books
// over, and a check that could not run says so, on the approval
// card and to the agent alike.
...(duplicate.status === 'overridden'
? {
complianceNote: `Bokförs trots att verifikat ${duplicate.candidate.voucher_label} (${duplicate.candidate.entry_date}) redan ser ut att bokföra betalningen (force=true).`,
}
: duplicateCheckFailed
? { complianceNote: DUPLICATE_CHECK_FAILED_NOTE }
: {}),
},
)
},
},
@@ -10786,7 +10929,7 @@ export const tools: McpTool[] = [
name: 'gnubok_match_batch_allocate',
keywords: ['klumpbetalning', 'fördela betalning', 'matcha betalningar'],
title: 'Batch-Allocate Payment',
description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices or one transfer pays many bills. Stages.',
description: 'Allocate 1 bank tx across N customer OR N supplier invoices: one receipt covering many invoices, one transfer paying many bills (samlingsbetalning, BFL 5 kap 6§). Stages.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -10808,6 +10951,8 @@ export const tools: McpTool[] = [
required: ['kind', 'amount'],
},
},
force: { type: 'boolean', description: 'Override a BATCH_TX_POSSIBLE_DUPLICATE refusal.' },
expected_journal_entry_ids: { type: 'array', items: { type: 'string' }, maxItems: 10, description: 'With force: exactly the ids the refusal named.' },
},
required: ['transaction_id', 'allocations'],
},
@@ -10825,6 +10970,25 @@ export const tools: McpTool[] = [
if (!Array.isArray(allocations) || allocations.length === 0) {
throw new Error('allocations is required (non-empty array)')
}
// No host validates inputSchema at runtime: check the override shape
// here (array, 1 to 10 strings, the schema's contract) so a malformed
// binding is a validation error, never a silently filtered one that
// then reads as a force mismatch.
const force = args.force === true
const rawExpectedIds = args.expected_journal_entry_ids
if (
rawExpectedIds !== undefined &&
(!Array.isArray(rawExpectedIds) ||
rawExpectedIds.length === 0 ||
rawExpectedIds.length > 10 ||
rawExpectedIds.some((v) => typeof v !== 'string' || !v))
) {
throw codedError('VALIDATION_ERROR', 'expected_journal_entry_ids must be an array of 1 to 10 journal_entry_id strings')
}
const expectedJournalEntryIds = rawExpectedIds as string[] | undefined
if (force && !expectedJournalEntryIds) {
throw codedError('VALIDATION_ERROR', 'expected_journal_entry_ids is required when force=true')
}
// kind is the key every guard below branches on (direction, required
// id, tenant pre-check). With kind absent, none of them fired: an
// incoming +50 359 SEK payment against three kundfakturor staged as
@@ -10841,9 +11005,11 @@ export const tools: McpTool[] = [
}
}
// amount_sek / exchange_rate / cash_account_id feed the already-explained
// guard below: it sums SEK legs on the row's own settlement account.
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, journal_entry_id')
.select('id, description, merchant_name, amount, currency, amount_sek, exchange_rate, cash_account_id, date, journal_entry_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
@@ -10953,6 +11119,37 @@ export const tools: McpTool[] = [
)
}
// Already-explained guard at stage time: the same detector + force
// binding the dashboard route runs (lib/invoices/already-explained-
// guard.ts). A Bankgirot aggregate whose invoices were each marked paid
// by hand is refused HERE, with the vouchers named, so the agent links
// the row to them instead of asking the user to approve a second
// booking. The commit executor re-runs the guard as the hard gate
// (issue #2294).
// A detector failure without force fails open, but never silently:
// the approval card carries the warning (preview.compliance_warning,
// rendered by GenericPreview) so the approver knows the check did not
// run. With force the guard returns 'unverifiable', refused below.
let explainedCheckFailed = false
const explained = await guardAlreadyExplained(
supabase,
companyId,
transaction,
{ force, expected_journal_entry_ids: expectedJournalEntryIds },
{
onDetectError: (err) => {
explainedCheckFailed = true
log.warn('match_batch_allocate: explaining-voucher detection failed (continuing)', { error: err instanceof Error ? err.message : String(err) })
},
},
)
if (explained.status === 'blocked') {
throw alreadyExplainedRefusal(explained, transactionId, transaction.cash_account_id ?? null)
}
if (explained.status === 'unverifiable') {
throw registryError('BATCH_TX_EXPLAINED_CHECK_FAILED')
}
const txDesc = transaction.merchant_name || transaction.description || transactionId
// Swedish plurals: kundfaktura → kundfakturor (not kundfakturaor).
// Same for leverantörsfaktura → leverantörsfakturor.
@@ -10961,7 +11158,13 @@ export const tools: McpTool[] = [
return stagePendingOperation(supabase, companyId, userId, 'match_batch_allocate',
`Fördela: ${txDesc}${summary}`,
{ transaction_id: transactionId, allocations },
{
transaction_id: transactionId,
allocations,
// The binding travels with the op so the commit executor can
// re-validate it against the set detected at commit time.
...(force ? { force: true, expected_journal_entry_ids: expectedJournalEntryIds } : {}),
},
// GDPR Art.25: transaction_description is included in preview_data
// so the user can recognise the tx at approval time (merchant_name
// or fallback to bank description). Same trade-off documented on
@@ -10983,7 +11186,19 @@ export const tools: McpTool[] = [
description: 'After approval the combined verifikat is created and each invoice is advanced. Verify with gnubok_get_ar_ledger (customer) or gnubok_get_supplier_ledger.',
tool: hasCustomer ? 'gnubok_get_ar_ledger' : 'gnubok_get_supplier_ledger',
},
{ dateForPeriodCheck: transaction.date }
{
dateForPeriodCheck: transaction.date,
// Never silent: an honoured override names the vouchers it books
// over, and a check that could not run says so, on the approval
// card and to the agent alike.
...(explained.status === 'overridden'
? {
complianceNote: `Bokförs trots att ${describeExplainingSet(explained.set)} redan förklarar raden (force=true).`,
}
: explainedCheckFailed
? { complianceNote: DUPLICATE_CHECK_FAILED_NOTE }
: {}),
},
)
},
},
+29
View File
@@ -698,6 +698,12 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
'Det finns redan en bokförd verifikation på samma belopp och datum. Har du redan bokfört denna betalning? Koppla bankhändelsen till befintlig verifikation, eller skapa ny verifikation ändå om de inte hör ihop.',
message_en:
'A posted journal entry already books the same amount on a nearby date. The user may have already booked this payment manually: link to the existing voucher or pass force=true to create a new one anyway.',
retryable: false,
remediation: {
description:
'Link the bank row to the existing voucher instead of booking a second one: gnubok_link_transaction_to_journal_entry (pass invoice_id to settle the kundfaktura at the same time) or gnubok_reconcile_match. Only if the row is a genuinely separate payment, call again with force=true and expected_journal_entry_id set to the id the refusal named.',
tool: 'gnubok_link_transaction_to_journal_entry',
},
},
MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH: {
httpStatus: 409,
@@ -3696,6 +3702,29 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
'Transaktionen ser redan ut att vara bokförd: en eller flera verifikationer utan bankkoppling summerar exakt till beloppet. Koppla transaktionen till dem i stället, eller bokför ändå om de inte hör ihop.',
message_en:
'The transaction already looks booked: one or more posted vouchers with no bank link add up exactly to its amount. Link the transaction to them instead, or pass force=true with expected_journal_entry_ids to book anyway.',
retryable: false,
remediation: {
description:
'Link the bank row to the vouchers the message names instead of booking it again: gnubok_reconcile_match with account_key "bank:<cash_account_id>" and one pair { external_ids: [transaction_id], journal_entry_ids: [...], allocations }. Only if the row is a genuinely separate affärshändelse, call again with force=true and expected_journal_entry_ids set to exactly the ids the refusal listed.',
tool: 'gnubok_reconcile_match',
},
},
// force=true reached a door whose already-explained check could not run:
// an override that cannot be re-verified against the current voucher set
// is refused, never waved through. Transient by nature (a ledger scan that
// timed out), hence retryable; a staged operation refused this way is
// auto-rejected and has to be staged again.
BATCH_TX_EXPLAINED_CHECK_FAILED: {
httpStatus: 409,
message_sv:
'Dubblettkontrollen kunde inte köras, så "bokför ändå" avvisades: ett åsidosättande som inte kan verifieras igen bokförs aldrig. Försök igen.',
message_en:
'The already-explained check could not run, so force=true was refused: an override that cannot be re-verified against the current vouchers is never honoured. Retry the request.',
retryable: true,
remediation: {
description:
'Retry after a short backoff. A staged operation refused this way is auto-rejected: stage it again with the same force + expected_journal_entry_ids, or link the row to the vouchers with gnubok_reconcile_match instead.',
},
},
BATCH_TX_ZERO_AMOUNT: {
httpStatus: 400,
@@ -0,0 +1,310 @@
/**
* The already-explained guards (lib/invoices/already-explained-guard.ts):
* one decision on top of the detectors, shared by the dashboard route, the
* MCP staging tools and the pending-operation commit (issue #2294). The
* detectors themselves are pinned by duplicate-payment-detection.test.ts;
* these tests cover the binding rules, the fail-open contract and the
* behandlingshistorik record an honoured override leaves.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockDetectSet, mockDetectCandidate, mockAppend } = vi.hoisted(() => ({
mockDetectSet: vi.fn(),
mockDetectCandidate: vi.fn(),
mockAppend: vi.fn(),
}))
vi.mock('../duplicate-payment-detection', () => ({
detectExplainingVoucherSetForTransaction: mockDetectSet,
detectDuplicatePaymentVoucher: mockDetectCandidate,
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: mockAppend,
}))
import {
alreadyExplainedDetails,
bindDuplicateCandidateOverride,
bindExplainedOverride,
describeExplainingSet,
guardAlreadyExplained,
guardDuplicatePaymentVoucher,
recordDuplicateCandidateOverride,
recordExplainedOverride,
} from '../already-explained-guard'
const JE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const JE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const JE_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const TX_ID = '11111111-1111-4111-8111-111111111111'
const set = {
vouchers: [
{ journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
{ journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
],
total: 88250,
bank_account_number: '1930',
same_date: true,
}
const candidate = {
journal_entry_id: JE_A,
voucher_label: 'A12',
entry_date: '2026-05-15',
description: 'Inbetalning faktura',
amount: 1000,
bank_account_number: '1930',
reason: 'exact_amount_same_date' as const,
amount_verified: true,
unverified_reason: null,
}
const supabase = {} as never
beforeEach(() => {
vi.clearAllMocks()
mockAppend.mockResolvedValue('evt-1')
})
describe('bindExplainedOverride (1:N set)', () => {
it('is clear when nothing explains the row, whatever the override says', () => {
expect(bindExplainedOverride(null, {})).toEqual({ status: 'clear' })
expect(bindExplainedOverride(null, { force: true, expected_journal_entry_ids: [JE_A] })).toEqual({ status: 'clear' })
})
it('blocks a set without force, and says force was not attempted', () => {
expect(bindExplainedOverride(set, {})).toEqual({ status: 'blocked', set, force_rejected: false })
// force without ids is no binding at all.
expect(bindExplainedOverride(set, { force: true })).toEqual({ status: 'blocked', set, force_rejected: true })
})
it('honours force only for exactly the detected ids, in any order', () => {
expect(bindExplainedOverride(set, { force: true, expected_journal_entry_ids: [JE_B, JE_A] })).toEqual({
status: 'overridden',
set,
})
})
it('refuses a subset, a superset and a swapped id: a stale review cannot wave the guard away', () => {
for (const ids of [[JE_A], [JE_A, JE_B, JE_C], [JE_A, JE_C]]) {
expect(bindExplainedOverride(set, { force: true, expected_journal_entry_ids: ids })).toEqual({
status: 'blocked',
set,
force_rejected: true,
})
}
})
it('never honours the ids without force', () => {
expect(bindExplainedOverride(set, { expected_journal_entry_ids: [JE_A, JE_B] })).toMatchObject({
status: 'blocked',
force_rejected: false,
})
})
it('hands every door the same details block', () => {
const outcome = bindExplainedOverride(set, { force: true, expected_journal_entry_ids: [JE_A] })
expect(outcome.status).toBe('blocked')
expect(alreadyExplainedDetails(outcome as Extract<typeof outcome, { status: 'blocked' }>)).toEqual({
vouchers: set.vouchers,
total: 88250,
bank_account_number: '1930',
same_date: true,
force_rejected: true,
})
})
})
describe('describeExplainingSet', () => {
it('names the vouchers, the account, the date and the SEK total', () => {
// sv-SE thousands separator is a (narrow) no-break space: match any space.
expect(describeExplainingSet(set)).toMatch(/^A57 \+ A58 \(1930, 2026-07-31, 88\s250,00 kr\)$/)
})
it('spells out a date range when the vouchers are spread', () => {
const spread = {
...set,
same_date: false,
vouchers: [set.vouchers[0], { ...set.vouchers[1], entry_date: '2026-08-02' }],
}
expect(describeExplainingSet(spread)).toContain('2026-07-31 till 2026-08-02')
})
})
describe('guardAlreadyExplained', () => {
it('passes the transaction (id or row) straight to the one detector', async () => {
mockDetectSet.mockResolvedValue(null)
const row = { id: TX_ID, date: '2026-07-31', amount: 88250, currency: 'SEK', cash_account_id: 'ca-1' }
await guardAlreadyExplained(supabase, 'company-1', row, {})
expect(mockDetectSet).toHaveBeenCalledWith(supabase, 'company-1', row)
await guardAlreadyExplained(supabase, 'company-1', TX_ID, {})
expect(mockDetectSet).toHaveBeenLastCalledWith(supabase, 'company-1', TX_ID)
})
it('binds the override to the detected set', async () => {
mockDetectSet.mockResolvedValue(set)
await expect(guardAlreadyExplained(supabase, 'company-1', TX_ID, {})).resolves.toMatchObject({ status: 'blocked' })
await expect(
guardAlreadyExplained(supabase, 'company-1', TX_ID, { force: true, expected_journal_entry_ids: [JE_A, JE_B] }),
).resolves.toMatchObject({ status: 'overridden' })
})
it('fails open when the detector throws without force, and reports the miss to the caller', async () => {
const boom = new Error('ledger scan timed out')
mockDetectSet.mockRejectedValue(boom)
const onDetectError = vi.fn()
await expect(guardAlreadyExplained(supabase, 'company-1', TX_ID, {}, { onDetectError })).resolves.toEqual({
status: 'clear',
})
expect(onDetectError).toHaveBeenCalledWith(boom)
})
it('refuses a forced override it cannot re-verify: a detector failure under force is never a pass', async () => {
const boom = new Error('ledger scan timed out')
mockDetectSet.mockRejectedValue(boom)
const onDetectError = vi.fn()
await expect(
guardAlreadyExplained(
supabase,
'company-1',
TX_ID,
{ force: true, expected_journal_entry_ids: [JE_A, JE_B] },
{ onDetectError },
),
).resolves.toEqual({ status: 'unverifiable', error: boom })
expect(onDetectError).toHaveBeenCalledWith(boom)
})
})
describe('recordExplainedOverride', () => {
it('writes a PII-free BankTransactionDuplicateDismissed record naming the vouchers', async () => {
await recordExplainedOverride('company-1', TX_ID, set, {
actor: { type: 'user', id: 'user-1' },
via: 'dashboard_force',
})
expect(mockAppend).toHaveBeenCalledTimes(1)
const input = mockAppend.mock.calls[0][0]
expect(input).toMatchObject({
companyId: 'company-1',
correlationId: TX_ID,
aggregateType: 'BankTransaction',
aggregateId: TX_ID,
eventType: 'BankTransactionDuplicateDismissed',
actor: { type: 'user', id: 'user-1' },
payload: {
transaction_id: TX_ID,
dismissed_journal_entry_ids: [JE_A, JE_B],
dismissed_voucher_labels: ['A57', 'A58'],
total_ore: 8825000,
bank_account_number: '1930',
same_date: true,
via: 'dashboard_force',
},
})
// Descriptions can carry counterparty names: they never enter the record.
expect(JSON.stringify(input.payload)).not.toContain('kundfaktura')
expect(input.occurredAt).toBeInstanceOf(Date)
})
it('is best-effort: a failed append is reported, never thrown', async () => {
const boom = new Error('insert failed')
mockAppend.mockRejectedValue(boom)
const onError = vi.fn()
await expect(
recordExplainedOverride('company-1', TX_ID, set, { actor: { type: 'user', id: 'user-1' }, via: 'x' }, onError),
).resolves.toBeUndefined()
expect(onError).toHaveBeenCalledWith(boom)
})
})
describe('bindDuplicateCandidateOverride (1:1 candidate)', () => {
it('is clear with no candidate and no force', () => {
expect(bindDuplicateCandidateOverride(null, {})).toEqual({ status: 'clear' })
})
it('blocks a candidate without force', () => {
expect(bindDuplicateCandidateOverride(candidate, {})).toEqual({ status: 'blocked', candidate })
expect(bindDuplicateCandidateOverride(candidate, { expected_journal_entry_id: JE_A })).toEqual({
status: 'blocked',
candidate,
})
})
it('honours force only for the exact candidate echoed back', () => {
expect(bindDuplicateCandidateOverride(candidate, { force: true, expected_journal_entry_id: JE_A })).toEqual({
status: 'overridden',
candidate,
})
})
it('treats a stale id, a missing id and a vanished candidate as a mismatch (the dashboard contract)', () => {
expect(bindDuplicateCandidateOverride(candidate, { force: true, expected_journal_entry_id: JE_B })).toEqual({
status: 'mismatch',
expected_journal_entry_id: JE_B,
detected_journal_entry_id: JE_A,
})
expect(bindDuplicateCandidateOverride(candidate, { force: true })).toEqual({
status: 'mismatch',
expected_journal_entry_id: null,
detected_journal_entry_id: JE_A,
})
expect(bindDuplicateCandidateOverride(null, { force: true, expected_journal_entry_id: JE_A })).toEqual({
status: 'mismatch',
expected_journal_entry_id: JE_A,
detected_journal_entry_id: null,
})
})
})
describe('guardDuplicatePaymentVoucher', () => {
const tx = { id: TX_ID, date: '2026-05-15', amount: 1000, currency: 'EUR', amount_sek: 11500, exchange_rate: 11.5 }
it('feeds the SEK conversion fields to the detector', async () => {
mockDetectCandidate.mockResolvedValue(null)
await expect(guardDuplicatePaymentVoucher(supabase, 'company-1', tx, {})).resolves.toEqual({ status: 'clear' })
expect(mockDetectCandidate).toHaveBeenCalledWith(supabase, {
companyId: 'company-1',
transactionId: TX_ID,
transactionDate: '2026-05-15',
transactionAmount: 1000,
transactionCurrency: 'EUR',
transactionAmountSek: 11500,
transactionExchangeRate: 11.5,
})
})
it('fails open without force, but refuses an override it cannot re-verify', async () => {
const boom = new Error('scan failed')
mockDetectCandidate.mockRejectedValue(boom)
const onDetectError = vi.fn()
await expect(guardDuplicatePaymentVoucher(supabase, 'company-1', tx, {}, { onDetectError })).resolves.toEqual({
status: 'clear',
})
await expect(
guardDuplicatePaymentVoucher(supabase, 'company-1', tx, { force: true, expected_journal_entry_id: JE_A }, { onDetectError }),
).resolves.toEqual({ status: 'mismatch', expected_journal_entry_id: JE_A, detected_journal_entry_id: null })
expect(onDetectError).toHaveBeenCalledTimes(2)
})
})
describe('recordDuplicateCandidateOverride', () => {
it('writes the singular dismissal shape categorize-core uses', async () => {
await recordDuplicateCandidateOverride('company-1', TX_ID, candidate, {
actor: { type: 'user', id: 'user-1' },
via: 'pending_operation_force',
})
expect(mockAppend.mock.calls[0][0]).toMatchObject({
eventType: 'BankTransactionDuplicateDismissed',
aggregateId: TX_ID,
payload: {
transaction_id: TX_ID,
dismissed_journal_entry_id: JE_A,
dismissed_voucher_label: 'A12',
amount_ore: 100000,
entry_date: '2026-05-15',
amount_verified: true,
via: 'pending_operation_force',
},
})
})
})
+302
View File
@@ -0,0 +1,302 @@
/**
* Already-explained guards for booking a bank row against invoices.
*
* 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 vouchers that explain the
* row are then on the ledger with no bank link, and the next door that books
* the row (a batch allocation, a single invoice match) mints a second
* verifikat for the same money. The DETECTORS for that live once, in
* duplicate-payment-detection.ts. This module is the one place that turns a
* detector's answer into a decision, so every door that books a bank row (the
* dashboard routes, MCP staging, the pending-operation commit) refuses,
* overrides and records in exactly the same way. A guard that lived in one
* door only is how the MCP path booked gecko's Bankgirot aggregate a second
* time (issue #2294, dashboard fix in PR #2300).
*
* Override binding: force=true is honoured only when the caller echoes the
* exact set (or candidate) detected NOW. A stale or guessed id is refused, so
* an approval given before another voucher was posted can never wave the
* guard away at commit time, and an automation cannot sweep through force=true
* without ever consulting the vouchers.
*
* Detection failures fail OPEN (the booking RPCs remain the atomicity
* boundary); callers log the miss through `onDetectError`.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
detectDuplicatePaymentVoucher,
detectExplainingVoucherSetForTransaction,
type DuplicateVoucherCandidate,
type ExplainingVoucher,
type ExplainingVoucherSet,
type TransactionForExplaining,
} from './duplicate-payment-detection'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import type { ProcessingHistoryActor } from '@/types'
// ── 1:N: the explaining voucher set (match_batch_allocate) ─────────────────
export interface ExplainedOverride {
/** Book anyway. Honoured only when expected_journal_entry_ids names the set detected now. */
force?: boolean
/** The journal_entry_ids of the set the caller reviewed. */
expected_journal_entry_ids?: string[]
}
export type AlreadyExplainedOutcome =
| { status: 'clear' }
| { status: 'blocked'; set: ExplainingVoucherSet; force_rejected: boolean }
| { status: 'overridden'; set: ExplainingVoucherSet }
/**
* force=true, but the detector failed, so the binding could not be
* re-verified. Never a pass: callers refuse it (BATCH_TX_EXPLAINED_CHECK_FAILED).
* Without force the same failure is 'clear' (fail-open; the caller logs and
* surfaces it).
*/
| { status: 'unverifiable'; error: unknown }
/** The details block every door hands back, so the dialog and the agent read one shape. */
export interface AlreadyExplainedDetails {
vouchers: ExplainingVoucher[]
total: number
bank_account_number: string
same_date: boolean
/** force=true was sent with a stale or missing set: the caller must re-read. */
force_rejected: boolean
}
/**
* Pure binding check. Order of the expected ids does not matter; the count
* and every id must match, so a subset or a superset of the set is refused.
*/
export function bindExplainedOverride(
set: ExplainingVoucherSet | null,
override: ExplainedOverride,
): AlreadyExplainedOutcome {
if (!set) return { status: 'clear' }
const detectedIds = set.vouchers.map((v) => v.journal_entry_id).sort()
const expectedIds = [...(override.expected_journal_entry_ids ?? [])].sort()
const bound =
override.force === true &&
detectedIds.length === expectedIds.length &&
detectedIds.every((id, i) => id === expectedIds[i])
if (!bound) return { status: 'blocked', set, force_rejected: override.force === true }
return { status: 'overridden', set }
}
export function alreadyExplainedDetails(
outcome: Extract<AlreadyExplainedOutcome, { status: 'blocked' }>,
): AlreadyExplainedDetails {
return {
vouchers: outcome.set.vouchers,
total: outcome.set.total,
bank_account_number: outcome.set.bank_account_number,
same_date: outcome.set.same_date,
force_rejected: outcome.force_rejected,
}
}
const sek = (amount: number): string =>
`${amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} kr`
/** "A57 + A58 (1930, 2026-07-31, 88 250,00 kr)": the set as a human reads it. */
export function describeExplainingSet(set: ExplainingVoucherSet): string {
const labels = set.vouchers.map((v) => v.voucher_label).join(' + ')
const dates = Array.from(new Set(set.vouchers.map((v) => v.entry_date))).sort()
const when = dates.length === 1 ? dates[0] : `${dates[0]} till ${dates[dates.length - 1]}`
return `${labels} (${set.bank_account_number}, ${when}, ${sek(set.total)})`
}
export interface GuardOptions {
/** Called when the detector throws; the guard then fails open. */
onDetectError?: (err: unknown) => void
}
/**
* Detect the explaining set for a bank row and bind the caller's override to
* it. Accepts the transaction id (one fetch) or a row the caller already
* holds, exactly like the detector. A detector failure fails open without
* force; with force it is 'unverifiable', because an override that cannot be
* re-verified against the current set must never be honoured (same rule as
* guardDuplicatePaymentVoucher below).
*/
export async function guardAlreadyExplained(
supabase: SupabaseClient,
companyId: string,
transaction: string | TransactionForExplaining,
override: ExplainedOverride,
options: GuardOptions = {},
): Promise<AlreadyExplainedOutcome> {
let set: ExplainingVoucherSet | null = null
try {
set = await detectExplainingVoucherSetForTransaction(supabase, companyId, transaction)
} catch (err) {
options.onDetectError?.(err)
if (override.force === true) return { status: 'unverifiable', error: err }
}
return bindExplainedOverride(set, override)
}
export interface OverrideAudit {
actor: ProcessingHistoryActor
/** Which door honoured the override (e.g. 'dashboard', 'pending_operation_commit'). */
via: string
}
/**
* Durable record of an honoured override, written AFTER the booking succeeded
* (BFNAR 2013:2 p. 9.16: the decision to book over vouchers that already
* explain the row must be reconstructible). Best-effort: a failure here never
* undoes the booking. Payload is PII-safe by construction: ids, labels,
* amounts and dates only, never descriptions or counterparty names.
*/
export async function recordExplainedOverride(
companyId: string,
transactionId: string,
set: ExplainingVoucherSet,
audit: OverrideAudit,
onError?: (err: unknown) => void,
): Promise<void> {
try {
await appendProcessingHistory({
companyId,
correlationId: transactionId,
aggregateType: 'BankTransaction',
aggregateId: transactionId,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: transactionId,
dismissed_journal_entry_ids: set.vouchers.map((v) => v.journal_entry_id),
dismissed_voucher_labels: set.vouchers.map((v) => v.voucher_label),
total_ore: Math.round(set.total * 100),
bank_account_number: set.bank_account_number,
same_date: set.same_date,
via: audit.via,
},
actor: audit.actor,
occurredAt: new Date(),
})
} catch (err) {
onError?.(err)
}
}
// ── 1:1: the single duplicate candidate (match_transaction_invoice) ────────
export interface DuplicateCandidateOverride {
force?: boolean
/** The journal_entry_id of the candidate the caller reviewed. Required with force. */
expected_journal_entry_id?: string
}
export type DuplicateCandidateOutcome =
| { status: 'clear' }
| { status: 'blocked'; candidate: DuplicateVoucherCandidate }
/** force=true, but the candidate detected now is not the one echoed (or there is none). */
| { status: 'mismatch'; expected_journal_entry_id: string | null; detected_journal_entry_id: string | null }
| { status: 'overridden'; candidate: DuplicateVoucherCandidate }
/**
* Pure binding check, same semantics as the dashboard and v1 match-invoice
* routes: without force a candidate blocks; with force the candidate detected
* now must be exactly the echoed one, and "no candidate" under force is a
* mismatch too (force is moot: the caller should retry without it).
*/
export function bindDuplicateCandidateOverride(
candidate: DuplicateVoucherCandidate | null,
override: DuplicateCandidateOverride,
): DuplicateCandidateOutcome {
if (override.force !== true) {
return candidate ? { status: 'blocked', candidate } : { status: 'clear' }
}
const expected = override.expected_journal_entry_id ?? null
if (!candidate || !expected || candidate.journal_entry_id !== expected) {
return {
status: 'mismatch',
expected_journal_entry_id: expected,
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
}
}
return { status: 'overridden', candidate }
}
/** The transaction columns the 1:1 detector needs. */
export interface TransactionForDuplicate {
id: string
date: string
amount: number
currency: string | null
amount_sek?: number | null
exchange_rate?: number | null
}
/**
* Detect the 1:1 duplicate candidate for an inbound bank row and bind the
* caller's override to it. A detection failure fails open without force; with
* force it is a mismatch, because an override that cannot be re-verified must
* not be honoured.
*/
export async function guardDuplicatePaymentVoucher(
supabase: SupabaseClient,
companyId: string,
transaction: TransactionForDuplicate,
override: DuplicateCandidateOverride,
options: GuardOptions = {},
): Promise<DuplicateCandidateOutcome> {
let candidate: DuplicateVoucherCandidate | null = null
try {
candidate = await detectDuplicatePaymentVoucher(supabase, {
companyId,
transactionId: transaction.id,
transactionDate: transaction.date,
transactionAmount: Number(transaction.amount),
transactionCurrency: transaction.currency ?? null,
transactionAmountSek: transaction.amount_sek ?? null,
transactionExchangeRate: transaction.exchange_rate ?? null,
})
} catch (err) {
options.onDetectError?.(err)
if (override.force === true) {
return {
status: 'mismatch',
expected_journal_entry_id: override.expected_journal_entry_id ?? null,
detected_journal_entry_id: null,
}
}
}
return bindDuplicateCandidateOverride(candidate, override)
}
/** Same durable record for the 1:1 override; the singular payload shape categorize-core writes. */
export async function recordDuplicateCandidateOverride(
companyId: string,
transactionId: string,
candidate: DuplicateVoucherCandidate,
audit: OverrideAudit,
onError?: (err: unknown) => void,
): Promise<void> {
try {
await appendProcessingHistory({
companyId,
correlationId: transactionId,
aggregateType: 'BankTransaction',
aggregateId: transactionId,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: transactionId,
dismissed_journal_entry_id: candidate.journal_entry_id,
dismissed_voucher_label: candidate.voucher_label,
amount_ore: Math.round(candidate.amount * 100),
entry_date: candidate.entry_date,
amount_verified: candidate.amount_verified,
unverified_reason: candidate.unverified_reason,
via: audit.via,
},
actor: audit.actor,
occurredAt: new Date(),
})
} catch (err) {
onError?.(err)
}
}
@@ -44,6 +44,14 @@ vi.mock('@/lib/bookkeeping/engine', async () => {
}
})
// The soft-duplicate guard runs before the storno (issue #2294). Mocked clean
// so it consumes no slot in the queued Supabase mock; its behaviour is pinned
// by match-transaction-invoice-duplicate-guard.test.ts.
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectDuplicatePaymentVoucher: vi.fn(async () => null),
detectExplainingVoucherSetForTransaction: vi.fn(async () => null),
}))
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
@@ -25,6 +25,22 @@ vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
clearSettledInvoiceSuggestions: mockClearSuggestions,
}))
// The already-explained guard runs before the RPC (issue #2294). The detector
// is mocked so it consumes no slot in the queued Supabase mock (its query
// shape is pinned by lib/invoices/__tests__/duplicate-payment-detection.test.ts);
// the binding logic on top of it is real.
const { mockDetectExplaining, mockAppendProcessingHistory } = vi.hoisted(() => ({
mockDetectExplaining: vi.fn(),
mockAppendProcessingHistory: vi.fn(),
}))
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectExplainingVoucherSetForTransaction: mockDetectExplaining,
detectDuplicatePaymentVoucher: vi.fn(async () => null),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: mockAppendProcessingHistory,
}))
import { commitPendingOperation } from '../commit'
const TX_ID = '11111111-1111-4111-8111-111111111111'
@@ -61,6 +77,8 @@ const REQUEST_ALLOCATIONS = [
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockDetectExplaining.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
})
describe('commitPendingOperation: match_batch_allocate suggestion cleanup', () => {
@@ -170,3 +188,145 @@ describe('commitPendingOperation: match_batch_allocate suggestion cleanup', () =
expect(mockClearSuggestions).not.toHaveBeenCalled()
})
})
/**
* Issue #2294: commit is the last gate. The same explaining-set detector the
* dashboard route runs (PR #2300) refuses the RPC when posted, unlinked
* vouchers already sum to the row, and a force binding staged earlier is
* re-validated against the set detected NOW.
*/
describe('commitPendingOperation: match_batch_allocate already-explained guard', () => {
const JE_A = '55555555-5555-4555-8555-555555555555'
const JE_B = '66666666-6666-4666-8666-666666666666'
const explainingSet = {
vouchers: [
{ journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
{ journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
],
total: 88250,
bank_account_number: '1930',
same_date: true,
}
const allocations = [{ kind: 'customer_invoice', invoice_id: INV_ID, amount: 88250 }]
const rpcOk = {
ok: true,
journal_entry_id: 'je-batch-9',
voucher_series: 'A',
voucher_number: 59,
tx_id: TX_ID,
allocations: [
{ kind: 'customer_invoice', invoice_id: INV_ID, payment_id: 'ip-9', status: 'paid', paid_amount: 88250, remaining_amount: 0, amount: 88250 },
],
total_allocated: 88250,
leftover: 0,
}
it('auto-rejects (409) with the vouchers when unlinked vouchers already sum to the row, without reaching the RPC', async () => {
mockDetectExplaining.mockResolvedValue(explainingSet)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher rejection update
const op = makePendingOp({ transaction_id: TX_ID, allocations })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE')
expect(result.error).toContain('A57 + A58')
const details = result.data as { vouchers: Array<{ voucher_label: string }>; force_rejected: boolean }
expect(details.vouchers.map((v) => v.voucher_label)).toEqual(['A57', 'A58'])
expect(details.force_rejected).toBe(false)
expect(mockDetectExplaining).toHaveBeenCalledWith(supabase, 'company-1', TX_ID)
expect(supabase.rpc).not.toHaveBeenCalled()
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
it('books when the staged force binding names exactly the set detected now, and records the override', async () => {
mockDetectExplaining.mockResolvedValue(explainingSet)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: rpcOk, error: null }) // match_batch_allocate RPC
enqueue({ data: null, error: null }) // dispatcher finalize update
const op = makePendingOp({
transaction_id: TX_ID,
allocations,
force: true,
// Order must not matter.
expected_journal_entry_ids: [JE_B, JE_A],
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(supabase.rpc).toHaveBeenCalledTimes(1)
// Never silent: the override leaves a behandlingshistorik record naming
// the vouchers it booked over (BFNAR 2013:2 p. 9.16).
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory.mock.calls[0][0]).toMatchObject({
companyId: 'company-1',
aggregateType: 'BankTransaction',
aggregateId: TX_ID,
eventType: 'BankTransactionDuplicateDismissed',
actor: { type: 'user', id: 'user-1' },
payload: {
transaction_id: TX_ID,
dismissed_journal_entry_ids: [JE_A, JE_B],
dismissed_voucher_labels: ['A57', 'A58'],
total_ore: 8825000,
bank_account_number: '1930',
via: 'pending_operation_force',
},
})
})
it('refuses a stale force binding: the set detected at commit is not the one the approval named', async () => {
mockDetectExplaining.mockResolvedValue(explainingSet)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher rejection update
// Staged when only A57 existed; A58 was posted before approval.
const op = makePendingOp({ transaction_id: TX_ID, allocations, force: true, expected_journal_entry_ids: [JE_A] })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE')
expect((result.data as { force_rejected: boolean }).force_rejected).toBe(true)
expect(supabase.rpc).not.toHaveBeenCalled()
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
it('auto-rejects a forced approval when the detector throws at commit: the binding cannot be re-verified', async () => {
mockDetectExplaining.mockRejectedValue(new Error('ledger scan timed out'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher rejection update
const op = makePendingOp({ transaction_id: TX_ID, allocations, force: true, expected_journal_entry_ids: [JE_A, JE_B] })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('BATCH_TX_EXPLAINED_CHECK_FAILED')
expect(result.data).toEqual({ reason: 'detector_failed', force_rejected: true })
expect(supabase.rpc).not.toHaveBeenCalled()
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
it('fails open when the detector throws without force: the RPC still decides', async () => {
mockDetectExplaining.mockRejectedValue(new Error('ledger scan timed out'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: rpcOk, error: null }) // match_batch_allocate RPC
enqueue({ data: null, error: null }) // dispatcher finalize update
const op = makePendingOp({ transaction_id: TX_ID, allocations })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(supabase.rpc).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,233 @@
/**
* Soft-duplicate guard on the agent/MCP match-transaction-to-invoice commit
* path (`commitMatchTransactionInvoice` in lib/pending-operations/commit.ts),
* issue #2294.
*
* The dashboard and v1 match-invoice routes refuse with
* MATCH_INVOICE_POSSIBLE_DUPLICATE when a manual verifikation already books
* the receipt, and bind force to that candidate. This path bypassed the guard
* entirely. It now runs the same guard BEFORE the irreversible storno, and
* re-binds a staged force to the candidate detected at commit.
*/
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 mockReverseEntry = vi.fn()
const mockFindFiscalPeriod = vi.fn()
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', async () => {
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/engine')>('@/lib/bookkeeping/engine')
return {
...actual,
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
}
})
const { mockDetectCandidate, mockAppendProcessingHistory } = vi.hoisted(() => ({
mockDetectCandidate: vi.fn(),
mockAppendProcessingHistory: vi.fn(),
}))
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectDuplicatePaymentVoucher: mockDetectCandidate,
detectExplainingVoucherSetForTransaction: vi.fn(async () => null),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: mockAppendProcessingHistory,
}))
vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
clearSettledInvoiceSuggestions: vi.fn(async () => undefined),
}))
import { commitPendingOperation } from '../commit'
const JE_MANUAL = '55555555-5555-4555-8555-555555555555'
const JE_OTHER = '66666666-6666-4666-8666-666666666666'
function makePendingOp(params: Record<string, unknown>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'match_transaction_invoice',
status: 'pending',
title: 'test',
params,
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-05-03T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-03T00:00:00Z',
} as PendingOperation
}
const transaction = {
id: 'tx-1',
company_id: 'company-1',
amount: 1000,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
date: '2026-05-15',
invoice_id: null,
// A prior categorization: the storno must never run before the guard.
journal_entry_id: 'je-old',
cash_account_id: null,
}
const invoice = {
id: 'inv-1',
invoice_number: 'F-2026001',
status: 'sent',
total: 1000,
remaining_amount: 1000,
paid_amount: 0,
currency: 'SEK',
exchange_rate: null,
journal_entry_id: null,
credited_invoice_id: null,
customer: { name: 'Kund AB' },
}
const candidate = {
journal_entry_id: JE_MANUAL,
voucher_label: 'A12',
entry_date: '2026-05-15',
description: 'Inbetalning faktura',
amount: 1000,
bank_account_number: '1930',
reason: 'exact_amount_same_date',
amount_verified: true,
unverified_reason: null,
}
/** CAS claim + the two reads that precede the guard. */
function enqueuePreGuard(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: transaction, error: null }) // transaction fetch
enqueue({ data: invoice, error: null }) // invoice fetch
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockDetectCandidate.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
mockReverseEntry.mockResolvedValue({ id: 'je-storno' })
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-pay' })
})
describe('commitPendingOperation: match_transaction_invoice soft-duplicate guard', () => {
it('auto-rejects (409) with the candidate before the storno when a manual voucher already books the receipt', async () => {
mockDetectCandidate.mockResolvedValue(candidate)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueue({ data: null, error: null }) // dispatcher rejection update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ transaction_id: 'tx-1', invoice_id: 'inv-1' }),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('MATCH_INVOICE_POSSIBLE_DUPLICATE')
expect(result.error).toContain('A12')
expect((result.data as { candidate: { journal_entry_id: string } }).candidate.journal_entry_id).toBe(JE_MANUAL)
// Nothing irreversible happened: no storno, no payment voucher.
expect(mockReverseEntry).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
it('books when the staged force echoes the candidate detected now, and records the override after the booking', async () => {
mockDetectCandidate.mockResolvedValue(candidate)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: null, error: null }) // transactions unlink after storno
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher finalize update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ transaction_id: 'tx-1', invoice_id: 'inv-1', force: true, expected_journal_entry_id: JE_MANUAL }),
)
expect(result.status).toBe('committed')
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory.mock.calls[0][0]).toMatchObject({
companyId: 'company-1',
aggregateType: 'BankTransaction',
aggregateId: 'tx-1',
eventType: 'BankTransactionDuplicateDismissed',
actor: { type: 'user', id: 'user-1' },
payload: {
transaction_id: 'tx-1',
dismissed_journal_entry_id: JE_MANUAL,
dismissed_voucher_label: 'A12',
via: 'pending_operation_force',
},
})
})
it('refuses a stale force id as MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH, still before the storno', async () => {
mockDetectCandidate.mockResolvedValue(candidate)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueue({ data: null, error: null }) // dispatcher rejection update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ transaction_id: 'tx-1', invoice_id: 'inv-1', force: true, expected_journal_entry_id: JE_OTHER }),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH')
expect(result.data).toEqual({ expected_journal_entry_id: JE_OTHER, detected_journal_entry_id: JE_MANUAL })
expect(mockReverseEntry).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('fails open when the detector throws without force: the match proceeds', async () => {
mockDetectCandidate.mockRejectedValue(new Error('scan failed'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueuePreGuard(enqueue)
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: [], error: null }) // resolveSettlementAccount
enqueue({ data: null, error: null }) // transactions unlink after storno
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher finalize update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({ transaction_id: 'tx-1', invoice_id: 'inv-1' }),
)
expect(result.status).toBe('committed')
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
})
@@ -65,6 +65,14 @@ vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
clearSettledInvoiceSuggestions: mockClearSuggestions,
}))
// The soft-duplicate guard runs before the storno (issue #2294). Mocked clean
// so it consumes no slot in the queued Supabase mock; its behaviour is pinned
// by match-transaction-invoice-duplicate-guard.test.ts.
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectDuplicatePaymentVoucher: vi.fn(async () => null),
detectExplainingVoucherSetForTransaction: vi.fn(async () => null),
}))
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
+113
View File
@@ -94,6 +94,15 @@ import {
import { linkInvoiceToVoucher, type LinkInvoiceToVoucherResult } from '@/lib/invoices/voucher-matching'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import {
alreadyExplainedDetails,
describeExplainingSet,
guardAlreadyExplained,
guardDuplicatePaymentVoucher,
recordDuplicateCandidateOverride,
recordExplainedOverride,
type ExplainedOverride,
} from '@/lib/invoices/already-explained-guard'
import {
linkSupplierInvoiceToVoucher,
type LinkSupplierInvoiceToVoucherResult,
@@ -3306,6 +3315,45 @@ async function commitMatchTransactionInvoice(
return { error: 'Invoice is not in a matchable state', status: 409 }
}
// Soft-duplicate guard: parity with the dashboard and v1 match routes
// (MATCH_INVOICE_POSSIBLE_DUPLICATE), which this path bypassed. A manual
// verifikation that already books this receipt means the approved match
// would double-book it. Runs BEFORE the irreversible storno below, and
// re-binds force to the candidate detected NOW: an approval staged before
// the manual voucher was posted cannot slip through (issue #2294).
const duplicate = await guardDuplicatePaymentVoucher(
supabase,
companyId,
transaction,
{
force: params.force === true,
expected_journal_entry_id:
typeof params.expected_journal_entry_id === 'string' ? params.expected_journal_entry_id : undefined,
},
{ onDetectError: (err) => log.warn('match_transaction_invoice: duplicate detection failed (continuing)', err) },
)
if (duplicate.status === 'blocked') {
const entry = getErrorEntry('MATCH_INVOICE_POSSIBLE_DUPLICATE')
return {
error: `${entry?.message_sv ?? 'Det finns redan en bokförd verifikation på samma belopp och datum.'} (verifikat ${duplicate.candidate.voucher_label}, ${duplicate.candidate.entry_date})`,
errorCode: 'MATCH_INVOICE_POSSIBLE_DUPLICATE',
status: 409,
data: { candidate: duplicate.candidate },
}
}
if (duplicate.status === 'mismatch') {
const entry = getErrorEntry('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH')
return {
error: entry?.message_sv ?? 'Verifikationen som dubblettkontrollen visade matchar inte längre.',
errorCode: 'MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH',
status: 409,
data: {
expected_journal_entry_id: duplicate.expected_journal_entry_id,
detected_journal_entry_id: duplicate.detected_journal_entry_id,
},
}
}
// FX resolution: parity with the dashboard and v1 match routes. paidAmount
// MUST be denominated in the INVOICE's currency (the unit of
// invoices.paid_amount / remaining_amount and invoice_payments.amount).
@@ -3583,6 +3631,18 @@ async function commitMatchTransactionInvoice(
})
}
// The override was acted on: durable behandlingshistorik record (BFNAR
// 2013:2 p. 9.16), same event the categorize guard writes.
if (duplicate.status === 'overridden') {
await recordDuplicateCandidateOverride(
companyId,
transactionId,
duplicate.candidate,
{ actor: { type: 'user', id: userId }, via: 'pending_operation_force' },
(err) => log.warn('match_transaction_invoice: failed to record override behandlingshistorik', err),
)
}
// The invoice is now settled, so every OTHER transaction still carrying a
// suggestion pointer at it is dead: retire them (issue #1259). This
// operation's own row is cleared by the update just below.
@@ -6638,6 +6698,47 @@ async function commitMatchBatchAllocate(
if (!Array.isArray(allocations) || allocations.length === 0) {
return { error: 'allocations is required (non-empty array)', status: 400 }
}
// Already-explained guard: the RPC only knows the invoices in the request,
// so posted vouchers that already book this bank row (each invoice marked
// paid by hand, a Bankgirot aggregate) are invisible to it and the money
// gets booked a second time. Same detector + force binding as the
// dashboard route and the staging tool (lib/invoices/already-explained-
// guard.ts). Commit is the last gate: the binding is re-validated against
// the set detected NOW, so an approval staged before a voucher was posted
// cannot slip through (issue #2294). 409 auto-rejects the op with the
// vouchers in result_data.
const override: ExplainedOverride = {
force: params.force === true,
expected_journal_entry_ids: Array.isArray(params.expected_journal_entry_ids)
? (params.expected_journal_entry_ids as unknown[]).filter((v): v is string => typeof v === 'string')
: undefined,
}
const explained = await guardAlreadyExplained(supabase, companyId, txId, override, {
onDetectError: (err) => log.warn('match_batch_allocate: explaining-voucher detection failed (continuing)', err),
})
if (explained.status === 'blocked') {
const entry = getErrorEntry('BATCH_TX_POSSIBLE_DUPLICATE')
return {
error: `${entry?.message_sv ?? 'Transaktionen ser redan ut att vara bokförd.'} (${describeExplainingSet(explained.set)})`,
errorCode: 'BATCH_TX_POSSIBLE_DUPLICATE',
status: 409,
data: alreadyExplainedDetails(explained) as unknown as Record<string, unknown>,
}
}
if (explained.status === 'unverifiable') {
// force=true but the check could not run at commit: the staged binding
// cannot be re-verified, so the op is refused (auto-rejected), never
// waved through on the strength of an earlier review.
const entry = getErrorEntry('BATCH_TX_EXPLAINED_CHECK_FAILED')
return {
error: entry?.message_sv ?? 'Dubblettkontrollen kunde inte köras, så "bokför ändå" avvisades.',
errorCode: 'BATCH_TX_EXPLAINED_CHECK_FAILED',
status: 409,
data: { reason: 'detector_failed', force_rejected: true },
}
}
const { data, error } = await supabase.rpc('match_batch_allocate', {
p_tx_id: txId,
p_allocations: allocations,
@@ -6673,6 +6774,18 @@ async function commitMatchBatchAllocate(
// (app/api/transactions/[id]/match-batch/route.ts) so the two cannot drift.
await clearSettledBatchAllocationSuggestions(supabase, companyId, result.allocations ?? [], txId)
// The override was acted on: durable behandlingshistorik record (BFNAR
// 2013:2 p. 9.16), same event the categorize guard writes.
if (explained.status === 'overridden') {
await recordExplainedOverride(
companyId,
txId,
explained.set,
{ actor: { type: 'user', id: userId }, via: 'pending_operation_force' },
(err) => log.warn('match_batch_allocate: failed to record override behandlingshistorik', err),
)
}
// Structured audit-trail entry on success (compliance-swarm V16). Tx
// count + JE id + the source tx id only: no amounts, no
// counterparty identifiers, no descriptions. txId is included