* fix(transactions): allow re-linking a bank tx stranded on a reversed verifikat (#988) A transaction whose journal_entry_id points at a reversed/cancelled entry reads as "utan koppling" in the UI (the transactions page enriches only status='posted' links), yet the re-booking guards treated ANY non-null pointer as "already linked". So a storno'd/corrected transaction could never be linked to another verifikat or re-categorized: the exact symptom in issue #988. Add a shared hasLiveJournalEntryLink() predicate used by every re-booking guard (linkTransactionToJournalEntry, manualLink, categorize-core, and the MCP link stage-check): a pointer at a non-posted entry is treated as re-linkable, and the two optimistic-locked writes now lock on the exact previous pointer (null OR the stale id) instead of always .is(null), so the overwrite goes through race-safely. hasLiveJournalEntryLink fails closed on a read error so a transient blip can't detach a genuinely live link. The source was fixed in #726 (reverseEntry/correctEntry now detach/re-point the tx); this makes the guards self-heal for the pre-#726 backlog and any future best-effort miss. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): detect 0-row CAS before invoice effects; fix categorize commit test Addresses PR review (CodeRabbit Critical + CI): - link-journal-entry.ts: the tx UPDATE now .select('id') and treats a 0-row result as LINK_TX_TX_ALREADY_LINKED, failing BEFORE any invoice settlement / invoice_payments insert. Without this, a concurrent re-link that lost the CAS would still mark the invoice paid against a transaction we didn't link (same optimistic-lock contract manualLink already enforces). - pending-operations commit route test: the categorize_transaction "already categorized" case now enqueues the hasLiveJournalEntryLink status read (posted = live) so it still returns 409. This was the core-only CI failure: the new liveness read in categorize-core consumed a queued response. - Updated the link happy-path / invoice-race test enqueues to return a row for the now-selecting tx UPDATE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
55 lines
2.6 KiB
TypeScript
55 lines
2.6 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 } 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)
|
|
})
|
|
})
|