Files
accounted/lib/transactions/__tests__/link-journal-entry.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

108 lines
4.5 KiB
TypeScript

/**
* Unit tests for hasLiveJournalEntryLink.
*
* This is the predicate the re-booking guards (linkTransactionToJournalEntry,
* manualLink, categorize-core, the MCP stage-check) share to decide whether a
* transaction's journal_entry_id is a LIVE link that should block re-linking,
* or a stale pointer at a reversed/cancelled entry that the UI already shows as
* "utan koppling" and must stay re-linkable (issue #988).
*
* The end-to-end re-link behaviour is covered by the route test
* (app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts) and
* the pending-op commit test.
*/
import { describe, it, expect } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { hasLiveJournalEntryLink, linkTransactionToJournalEntry } from '../link-journal-entry'
describe('hasLiveJournalEntryLink', () => {
it('returns false for a null/undefined pointer without querying', async () => {
const { supabase } = createQueuedMockSupabase()
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', null)).toBe(false)
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', undefined)).toBe(false)
})
it('returns true when the entry is posted (a live link)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { status: 'posted' }, error: null })
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-1')).toBe(true)
})
it('returns false when the entry is reversed (stale link, #988)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { status: 'reversed' }, error: null })
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-1')).toBe(false)
})
it('returns false when the entry is cancelled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { status: 'cancelled' }, error: null })
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-1')).toBe(false)
})
it('returns false when the referenced entry row is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-gone')).toBe(false)
})
it('fails closed (returns true) on a read error so a live link is never clobbered', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'statement timeout' } })
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-1')).toBe(true)
})
})
describe('linkTransactionToJournalEntry: junction-row guard (#1553)', () => {
const base = {
id: 'tx-1',
date: '2026-06-11',
amount: -800,
currency: 'SEK',
exchange_rate: null,
journal_entry_id: null,
invoice_id: null,
is_business: null,
potential_invoice_id: null,
potential_supplier_invoice_id: null,
}
it('refuses a transaction anchored through a bank_line junction row (1:N split, bulk-book) even though the pointer is NULL', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: { ...base, transaction_voucher_links: [{ journal_entry_id: 'je-split-a', role: 'bank_line' }] },
error: null,
})
const outcome = await linkTransactionToJournalEntry(supabase as never, 'user-1', 'company-1', {
transactionId: 'tx-1',
journalEntryId: 'je-other',
})
expect(outcome).toEqual({
ok: false,
code: 'LINK_TX_TX_ALREADY_LINKED',
details: { existingJournalEntryId: 'je-split-a' },
})
expect(findCalls('transactions', 'update')).toEqual([])
})
it('lets a residual booking\'s "other" row through: the row must stay re-linkable after a storno of its main verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { ...base, transaction_voucher_links: [{ journal_entry_id: 'je-residual', role: 'other' }] },
error: null,
})
enqueue({ data: null, error: { message: 'not found' } }) // the verifikat lookup that follows the guard
const outcome = await linkTransactionToJournalEntry(supabase as never, 'user-1', 'company-1', {
transactionId: 'tx-1',
journalEntryId: 'je-other',
})
expect(outcome.ok).toBe(false)
expect((outcome as { code: string }).code).not.toBe('LINK_TX_TX_ALREADY_LINKED')
expect(supabase.from).toHaveBeenCalledWith('journal_entries')
})
})