a08bf51ced
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR 2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record "forandringar i bokforingssystemet som paverkar bokforingsposternas behandling samt nar dessa forandringar infordes", and BFN's commentary names behandlingsregler (automatkonteringar, fasta procentsatser) and new program versions as the examples. Until now both changed without a trace. Audit triggers on the behandlingsregler tables and the import logs: mapping_rules, booking_template_library, categorization_templates, salary_payroll_config, sie_imports, bank_file_imports. categorization_templates learns on every booking (occurrence_count, confidence, last_seen_date), so those telemetry-only updates are excluded by a WHEN clause the same way the api_keys request counters are (20260721115701): only real rule changes are logged. Measured against prod that is roughly 3 800 new audit rows a month against an audit_log already taking 371 688, so about +1 %. app_releases is an append-only log of program versions seen in production, written by the runtime the first time a build answers a request. Vercel exposes no build hook we can trust to write the row, so /api/version records it inside after(): the handler returns synchronously and a floating promise could be frozen before the insert lands, which is how a version log ends up silently empty. The service client is constructed lazily so the constantly polled public probe pays nothing once the module guard is set. Program versions are rolled up per Swedish calendar day in the report. main takes ~570 merges a month, so one event per version would be on the order of 7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the ~400 events a real company's year contains. The statutory unit is the date, and the same sentence qualifies the requirement to changes that affect processing, which a deploy list cannot distinguish anyway. app_releases keeps the per-version truth for anyone who needs to go deeper. AuditLogEntry.user_id becomes string | null. The column is nullable and write_audit_log() falls back to auth.uid(), which is NULL for a service-role or global write; the company-less salary_payroll_config rows are the first that routinely hit it, and the read model already coded for it. Also restores the point citations the 2026-07-27 pass removed while the chapter was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified against BFN's consolidated text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): fix two fixture bugs in the behandlingshistorik trigger tests pg-real caught both, and neither is in the migration: the inserts fail before the trigger is reached. mapping_rules.rule_type is constrained to mcc_code / merchant_name / description_pattern / amount_threshold / combined; the test used 'merchant'. booking_template_library's btl_insert policy requires current_user_can_write() and company_id = current_active_company_id(), so the authenticated insert needs a company_members row and a user_preferences.active_company_id, the same setup booking-template-hidden.pg.test.ts uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): assert the booking-template audit row inside the user transaction withUserContext always rolls back, so the audit row the trigger writes is gone before an outside connection can see it. The trigger fires in the same transaction as the write, so the assertion belongs there too. The other cases in this file write on the pool (autocommit) and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * fix(reports): name every build id in the per-day program-version entry Raised by the compliance review on #2097: the roll-up listed five ids and a count, which leaves an auditor unable to reconstruct which versions ran that day. app_releases keeps the full record, but the report is the surface anyone actually reads. A day is bounded by the deploy rate (~19), so the full list stays one readable cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
267 lines
10 KiB
TypeScript
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 p. 9.16).
|
|
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')
|
|
})
|
|
})
|