Files
accounted/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts
T
8e3015e541 fix(bookkeeping): book pre-FY bank transactions on the fiscal year's first day (#1828)
A newly registered company whose first rakenskapsar starts on the
Bolagsverket registration date could not book the aktiekapital deposit,
because the bank transaction is dated BEFORE the registration. Every
surface dead-ended: the manual booking dialog hard-blocked with the date
locked and only offered creating a (legally wrong) pre-registration
fiscal year, and the categorize paths either marked the row categorized
WITHOUT a verifikat ("Delvis bokforda") or silently minted a bogus
calendar-year period before the company existed.

Root cause: entry_date was hard-wired to the bank date with no clamp
against the company's first fiscal period, and the duplicated
ensureFiscalPeriod helpers upserted a calendar-year period for any
uncovered date.

Fix, per BFL (the event belongs to the first fiscal year; the real
affarshaendelse date is preserved on the verifikat):
- createTransactionJournalEntry clamps a pre-FY date into the earliest
  OPEN unlocked fiscal period with entry_date = period_start and appends
  "Affarshaendelse <date>, bokford pa rakenskapsarets forsta dag" to the
  verifikationstext. Interior gaps, future dates, and a closed/locked
  first year keep the old null return.
- Both ensureFiscalPeriod copies (categorize core + web route) skip the
  calendar-period upsert when the date predates the earliest period.
- JournalEntryForm's no_period block offers "Bokfor pa rakenskapsarets
  forsta dag (<date>)" for pre-FY dates instead of proposing a
  pre-registration year; "Skapa rakenskapsar" remains for the other
  no_period cases.

No schema, RPC, or trigger changes: the /book route and the DB triggers
already accept the clamped booking.

Fixes #1825


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:19:50 +02:00

266 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: [] }, // 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')
})
})