Files
accounted/lib/transactions/__tests__/inbox-underlag.test.ts
T
MattssonandClaude Fable 5 5769e35869 fix(api-v1): propagate underlag when booking via v1 categorize routes (#1564)
The v1 categorize and batch-categorize routes create the journal entry
via createTransactionJournalEntry directly and never ran the shared
underlag propagation, so a booking made through the API-key surface
left the transaction's pinned document unanchored and matched inbox
items unstamped: the same "Underlag saknas" gap #1560 closed for the
dashboard, /book and bulk-book paths, surviving on this one surface.

Both routes now call propagateUnderlagForBookedTransaction after the
CAS write succeeds (only when this request owns the booking; skipped on
partial success and lost CAS races). Best-effort by contract, same as
every other caller: a propagation failure is logged inside the helper
and never fails the booking.

Also adds the attach-after-bulk-book unit test salvaged from the closed
duplicate PR #1559: a document pinned to a bulk-booked transaction
(verifikat anchored via transaction_voucher_links) is anchored against
the samlingsverifikat when attached after the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 13:48:01 +02:00

359 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: vi.fn().mockResolvedValue({}),
}))
import {
completeInboxItemsForBookedTransaction,
propagateUnderlagForBookedTransaction,
resolveBookedJournalEntryIds,
} from '../inbox-underlag'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
const COMPANY = 'company-1'
const TX1 = 'tx-1'
const TX2 = 'tx-2'
const JE1 = 'je-1'
const JE2 = 'je-2'
describe('resolveBookedJournalEntryIds', () => {
beforeEach(() => vi.clearAllMocks())
it('returns an empty map without querying for an empty id list', async () => {
const { supabase, calls } = createQueuedMockSupabase()
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [])
expect(map.size).toBe(0)
expect(calls.length).toBe(0)
})
it('resolves direct journal_entry_id and falls back to voucher links', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{ id: TX1, journal_entry_id: JE1 },
{ id: TX2, journal_entry_id: null },
],
})
// Voucher-link fallback runs only for the unbooked remainder (TX2).
enqueue({ data: [{ transaction_id: TX2, journal_entry_id: JE2 }] })
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [
TX1,
TX2,
])
expect(map.get(TX1)).toBe(JE1)
expect(map.get(TX2)).toBe(JE2)
})
it('omits transactions booked by neither route', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: TX1, journal_entry_id: null }] })
enqueue({ data: [] }) // no voucher links either
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [
TX1,
])
expect(map.size).toBe(0)
})
})
describe('propagateUnderlagForBookedTransaction', () => {
beforeEach(() => vi.clearAllMocks())
it('links the document and stamps the matched item', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] }) // matched items
enqueue({ data: { journal_entry_id: null } }) // doc not yet anchored
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-1', JE1)
const stamps = findCalls('invoice_inbox_items', 'update')
expect(stamps).toContainEqual([{ created_journal_entry_id: JE1 }])
})
it('skips the document write when it already points at the verifikat', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored to JE1
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
it('never steals a document anchored to another verifikat, and does not stamp', async () => {
// The doc stays on its verifikat, but THIS transaction's verifikat then
// has no underlag from the item: stamping it consumed would hide the
// mismatch from every future run and from manual reconciliation
// (BFL 5 kap 6-7 §).
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: 'je-other' } })
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
it('stamps an item without a document (no document read)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: null }] })
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
expect(findCalls('document_attachments', 'select').length).toBe(0)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
it('tolerates the UNIQUE violation when a sibling item already claimed the verifikat', async () => {
// Samlingsverifikat: created_journal_entry_id is UNIQUE, so on N matched
// items only the first stamp lands. The rest must resolve quietly: the
// inbox list derives "booked" from the transaction's state for them.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: null }] })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } })
await expect(
propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
),
).resolves.toBeUndefined()
})
it('does NOT stamp when the document link fails, so a re-run can still repair it', async () => {
// Stamping over a failed link would hide the item from the
// `.is('created_journal_entry_id', null)` query forever, leaving a
// posted verifikation without its underlag reference (BFL 5 kap 6-7 §)
// and nothing left to surface or repair it.
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: null } })
await expect(
propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
),
).resolves.toBeUndefined()
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
// ── The transaction's own pinned document (transactions.document_id) ──
// A doc attached directly to the transaction has no inbox item to carry
// it, so the propagation must anchor it itself: this was the 2026-08-13
// "Underlag saknas" gap (attach-before-book via the manual booking dialog).
it('anchors the pinned document even when no inbox item exists', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } }) // tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc not yet anchored
enqueue({ data: [] }) // no matched inbox items
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-pin', JE1)
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
it('leaves a pinned document that already points at this verifikat alone', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } })
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored here
enqueue({ data: [] })
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('never steals a pinned document anchored to another verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } })
enqueue({ data: { journal_entry_id: 'je-other' } })
enqueue({ data: [] })
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('still processes inbox items when the pinned-document link fails', async () => {
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } })
enqueue({ data: { journal_entry_id: null } }) // pinned doc; link will throw
enqueue({ data: [{ id: 'i1', document_id: null }] }) // docless matched item
enqueue({ data: null }) // stamp update
await expect(
propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
),
).resolves.toBeUndefined()
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
it('links a document shared by the pin and a matched item only once', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-1' } }) // pin points at the same doc
enqueue({ data: { journal_entry_id: null } }) // pin leg links it
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] }) // matched item, same doc
enqueue({ data: { journal_entry_id: JE1 } }) // item leg finds it anchored
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).toHaveBeenCalledTimes(1)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
})
describe('completeInboxItemsForBookedTransaction', () => {
beforeEach(() => vi.clearAllMocks())
it('is a no-op for an unbooked transaction', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: TX1, journal_entry_id: null }] })
enqueue({ data: [] }) // no voucher links
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
)
expect(result).toBeNull()
expect(findCalls('invoice_inbox_items', 'select').length).toBe(0)
})
it('skips the resolution fetch when the caller passes the direct id', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup inside propagate
enqueue({ data: [] }) // matched items (none)
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
{ directJournalEntryId: JE1 },
)
expect(result).toBe(JE1)
// The only transactions read is the pin lookup inside the propagation:
// the id/journal_entry_id resolution query never runs.
expect(findCalls('transactions', 'select')).toEqual([['document_id']])
})
it('resolves the samlingsverifikat through voucher links when the direct id is null', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ transaction_id: TX1, journal_entry_id: JE2 }] }) // voucher links
enqueue({ data: { document_id: null } }) // tx pin lookup inside propagate
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched items
enqueue({ data: null }) // stamp update
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
{ directJournalEntryId: null },
)
expect(result).toBe(JE2)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE2 },
])
})
it('anchors a pinned document against the samlingsverifikat on attach-after-bulk-book', async () => {
// A bulk-booked tx keeps transactions.journal_entry_id null: its verifikat
// hangs off transaction_voucher_links. A document attached AFTER that
// booking (with no inbox item) must still land on the samlingsverifikat:
// voucher-link resolution feeds the pin leg of the propagation.
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ transaction_id: TX1, journal_entry_id: JE2 }] }) // voucher links
enqueue({ data: { document_id: 'doc-pin' } }) // tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc unanchored
enqueue({ data: [] }) // no matched inbox items
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
{ directJournalEntryId: null },
)
expect(result).toBe(JE2)
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-pin', JE2)
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
})