Files
accounted/lib/pending-operations/__tests__/reconciliation-match-executor.test.ts
T
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00

111 lines
4.2 KiB
TypeScript

/**
* commitReconciliationMatch, driven through the public commitPendingOperation
* dispatcher. The linking lives in lib/reconciliation/actions.ts matchPairs
* (unit tested there); these tests cover the wiring for the bank 1:N pair of
* issue #1553: the staged pair (one row, several verifikat, allocations)
* reaches matchPairs intact and as ONE pair, never re-split into independent
* 1:1 links, and partial success surfaces in the committed payload.
*/
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 matchMock = vi.fn()
vi.mock('@/lib/reconciliation/actions', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/actions')>('@/lib/reconciliation/actions')
return { ...actual, matchPairs: (...args: unknown[]) => matchMock(...args) }
})
import { commitPendingOperation } from '../commit'
const CASH = '11111111-1111-4111-8111-111111111111'
const KEY = `bank:${CASH}`
const T1 = '22222222-2222-4222-8222-222222222222'
const E1 = '44444444-4444-4444-8444-444444444444'
const E2 = '55555555-5555-4555-8555-555555555555'
const splitPair = {
external_ids: [T1],
journal_entry_ids: [E1, E2],
allocations: [
{ journal_entry_id: E1, amount: -500 },
{ journal_entry_id: E2, amount: -300 },
],
}
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'reconciliation_match',
status: 'pending',
title: 'test',
params: { account_key: KEY, pairs: [splitPair] },
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-08-29T00:00:00Z',
resolved_at: null,
updated_at: '2026-08-29T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: reconciliation_match with a bank 1:N pair (#1553)', () => {
it('passes the staged split pair, allocations included, to matchPairs as one pair', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's committed update
matchMock.mockResolvedValue({
dry_run: false,
considered: 1,
applied: [
{ external_id: T1, journal_entry_id: E1, allocated_amount: -500 },
{ external_id: T1, journal_entry_id: E2, allocated_amount: -300 },
],
skipped: [],
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(matchMock).toHaveBeenCalledTimes(1)
expect(matchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', KEY, { pairs: [splitPair] }, { dryRun: false })
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ account_key: KEY, applied_count: 2, skipped_count: 0 })
expect((result.data as { applied: Array<{ allocated_amount: number }> }).applied.map((a) => a.allocated_amount)).toEqual([-500, -300])
})
it('reports a refused split (sum mismatch at commit time) as a 409 auto-reject carrying the skip code', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's failed update
matchMock.mockResolvedValue({
dry_run: false,
considered: 1,
applied: [],
skipped: [
{ pair: splitPair, code: 'PAIR_NOT_CLOSED', message: 'Fördelningen (-800) stämmer inte med transaktionens belopp (-900).' },
],
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
// 409 is a conflict with the ledger's current state, which the
// dispatcher records as rejected (like a 404), not as a failed attempt.
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('PAIR_NOT_CLOSED')
expect(result.error).toMatch(/PAIR_NOT_CLOSED/)
})
})