Files
accounted/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts
T
Jakob Wennberg 7cf15a105f fix(bookkeeping): settle unbound transactions on the company's single enabled cash account (#1831)
* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account

A transaction with no cash_account_id booked its bank leg on the
hardcoded 1930 from the standard templates and category mappings even
when the company's only bank account is e.g. 1920 (PlusGiro), while the
booking dialogs previewed the right account via the client-side
resolveAccount fallback. resolveSettlementAccount now mirrors that
fallback: with a NULL cash_account_id it lists the company's enabled
cash accounts and, when EXACTLY ONE matches the transaction's currency,
settles there; zero or several candidates keep the 1930 fallback. The
explicit-cash_account_id branch (including its throw-on-error path,
issue #842) is byte-identical. Transaction currency is threaded into
the categorize, batch-categorize, pending-operation edit, MCP staging,
and invoice-inbox call sites; other callers get the SEK default.

Forward-only: historical wrong verifikat are corrected only via the
existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md).

Fixes #1722

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

* test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups

The merge of main (PR #1828) into this branch combined two changes that each
add one query to the categorize commit flow; the strictly ordered queued mock
in the allow_duplicate test needed the cash_accounts listing entry inserted
between the period lookup and the pre-FY clamp lookup.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:26:08 +02:00

267 lines
10 KiB
TypeScript

/**
* The agent/MCP commit path (lib/pending-operations/commit.ts) must run the same
* duplicate guards as the web routes: it previously bypassed them entirely,
* which let an approved staged op double-book an affärshändelse already in the
* ledger (the production case: a bank line booked on top of an invoice
* "markera som betald" voucher or a salary payout).
*
* These tests drive the public `commitPendingOperation` dispatcher (the executor
* functions are private) and assert the op is auto-rejected (409) when a
* duplicate is detected. The detection functions themselves are unit-tested in
* lib/transactions/__tests__/booking-duplicate-detection.test.ts and
* lib/invoices/__tests__/duplicate-payment-detection.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import type { PendingOperation } from '@/types'
const mockDetectBookingDuplicate = vi.fn()
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: (...args: unknown[]) => mockDetectBookingDuplicate(...args),
}))
const mockFindDupPayments = vi.fn()
vi.mock('@/lib/invoices/duplicate-payment-candidates', () => ({
findDuplicatePaymentCandidatesForInvoice: (...args: unknown[]) => mockFindDupPayments(...args),
}))
const mockAppendProcessingHistory = vi.fn()
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: (...args: unknown[]) => mockAppendProcessingHistory(...args),
}))
import { commitPendingOperation } from '../commit'
/** Queue-based supabase mock: each `from()` resolves to the next queued result. */
function queuedSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const from = vi.fn(() => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return () => chain
},
},
)
return chain
})
return { from } as never
}
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
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',
...overrides,
} as PendingOperation
}
const voucherCandidate = {
transaction_id: null,
journal_entry_id: 'je-existing',
voucher_label: 'A2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
amount: 98565,
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commit duplicate guard: categorize_transaction (reverse / book the bank line)', () => {
it('auto-rejects (409) when a ledger voucher already books this movement', async () => {
mockDetectBookingDuplicate.mockResolvedValue(voucherCandidate)
// claim → transaction fetch → reject update
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: null },
])
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'income' },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('does not enforce the guard when allow_duplicate=true, but records the dismissal to behandlingshistorik', async () => {
mockDetectBookingDuplicate.mockResolvedValue(voucherCandidate)
// The booking proceeds past the guard (not auto-rejected); the downstream
// booking is allowed to fail against the bare mock. Before that, the bypass
// must leave a durable BankTransactionDuplicateDismissed record so an
// auditor can reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8).
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [] }, // no fiscal period yet
{ data: [] }, // pre-FY guard: earliest-period lookup (none yet)
{ data: null }, // fiscal-period upsert
{ data: null }, // journal-entry period lookup: partial categorization path
{ data: [] }, // resolveSettlementAccount: no enabled cash accounts -> 1930
{ data: [] }, // pre-FY clamp: earliest-period lookup (none yet)
{
data: [{
id: 'tx-1',
date: '2026-03-26',
amount: 98565,
cash_account_id: null,
journal_entry_id: null,
is_ignored: false,
}],
}, // guarded transaction update matched
])
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'income', allow_duplicate: true },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
// Guard not enforced: the op is not auto-rejected at the duplicate guard.
expect(result.status).not.toBe('rejected')
// Detection still runs once: to capture the dismissed candidate for audit.
expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
const event = mockAppendProcessingHistory.mock.calls[0][0]
expect(event).toMatchObject({
companyId: 'company-1',
aggregateType: 'BankTransaction',
aggregateId: 'tx-1',
eventType: 'BankTransactionDuplicateDismissed',
actor: { type: 'user', id: 'user-1' },
})
expect(event.payload).toMatchObject({
transaction_id: 'tx-1',
dismissed_journal_entry_id: 'je-existing',
via: 'allow_duplicate',
})
})
it('records no dismissal when allow_duplicate=true but no duplicate is actually present', async () => {
mockDetectBookingDuplicate.mockResolvedValue(null)
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [] }, // no fiscal period yet
{ data: [] }, // pre-FY guard: earliest-period lookup (none yet)
{ data: null }, // fiscal-period upsert
{ data: null }, // journal-entry period lookup: partial categorization path
{ data: [] }, // pre-FY clamp: earliest-period lookup (none yet)
{
data: [{
id: 'tx-1',
date: '2026-03-26',
amount: 98565,
cash_account_id: null,
journal_entry_id: null,
is_ignored: false,
}],
}, // guarded transaction update matched
])
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'income', allow_duplicate: true },
})
await commitPendingOperation(supabase, 'user-1', 'company-1', op)
expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
})
describe('commit duplicate guard: mark_invoice_paid (forward / book the payment)', () => {
it('auto-rejects (409) when an unlinked bank transaction already looks like the payment', async () => {
mockFindDupPayments.mockResolvedValue([
{ id: 'tx-9', date: '2026-03-26', amount: 98565, description: '2026001', merchant_name: null, reference: null, match_reason: 'ocr_exact', match_confidence: 0.99 },
])
// claim → invoice fetch → reject update
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'inv-1', invoice_number: '2026001', status: 'sent', total: 98565, remaining_amount: 98565, customer: { name: 'Arcim Technology AB' } } },
{ data: null },
])
const op = makePendingOp({
operation_type: 'mark_invoice_paid',
params: { invoice_id: 'inv-1', payment_date: '2026-03-30' },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
expect(mockFindDupPayments).toHaveBeenCalledTimes(1)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('does not enforce the guard when allow_duplicate=true, but records the dismissal to behandlingshistorik', async () => {
mockFindDupPayments.mockResolvedValue([
{ id: 'tx-9', date: '2026-03-26', amount: 98565, description: '2026001', merchant_name: null, reference: null, match_reason: 'ocr_exact', match_confidence: 0.99 },
])
// claim → invoice fetch → company_settings → bare downstream (allowed to fail)
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'inv-1', invoice_number: '2026001', status: 'sent', total: 98565, remaining_amount: 98565, customer: { name: 'Arcim Technology AB' } } },
{ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' } },
])
const op = makePendingOp({
operation_type: 'mark_invoice_paid',
params: { invoice_id: 'inv-1', payment_date: '2026-03-30', allow_duplicate: true },
})
const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op)
// Guard not enforced: not auto-rejected at the duplicate-payment guard.
expect(result.status).not.toBe('rejected')
expect(mockFindDupPayments).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1)
const event = mockAppendProcessingHistory.mock.calls[0][0]
expect(event).toMatchObject({
companyId: 'company-1',
aggregateType: 'System',
aggregateId: 'inv-1',
eventType: 'InvoiceDuplicatePaymentDismissed',
actor: { type: 'user', id: 'user-1' },
})
expect(event.payload).toMatchObject({
invoice_id: 'inv-1',
dismissed_transaction_ids: ['tx-9'],
candidate_count: 1,
via: 'allow_duplicate',
})
// PII-safe: no customer or merchant name in the payload.
expect(JSON.stringify(event.payload)).not.toContain('Arcim')
})
})