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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
39d409d257
commit
cce0de5704
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user