* 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
dcd33997b7
commit
f04dc4c4e0
@@ -709,12 +709,13 @@ describe('manualLink', () => {
|
||||
expect(result.error).toBe('Transaktionen kunde inte hittas.')
|
||||
})
|
||||
|
||||
it('rejects when transaction is already linked', async () => {
|
||||
it('rejects when transaction is already linked to a LIVE (posted) entry', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'je-existing' })
|
||||
|
||||
// Transaction found but already linked
|
||||
// Transaction found, still pointing at a live posted verifikat
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { status: 'posted' } }) // hasLiveJournalEntryLink: prior link is live
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
||||
|
||||
@@ -722,6 +723,24 @@ describe('manualLink', () => {
|
||||
expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
|
||||
})
|
||||
|
||||
it('re-links a transaction stranded on a reversed entry (#988)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// Still carries a pointer at a status='reversed' entry (storno/correction
|
||||
// left it behind). The UI shows it as "utan koppling", so manualLink must
|
||||
// treat it as free and overwrite the stale pointer.
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'je-reversed' })
|
||||
|
||||
enqueue({ data: tx }) // tx fetch
|
||||
enqueue({ data: { status: 'reversed' } }) // hasLiveJournalEntryLink: stale link
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) // target JE fetch
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] }) // line on 1930
|
||||
enqueue({ data: [{ id: 'tx-1' }] }) // UPDATE .eq(stale id) → 1 row overwritten
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when journal entry has no line on the selected account', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
|
||||
Reference in New Issue
Block a user