Files
accounted/lib/transactions/__tests__/inbox-underlag.test.ts
T
Jakob Wennberg 6a40b3c0ef fix(inbox): name the FK in the underlag lock-state embed so locked items classify (#1548) (#2022)
fiscal_periods carries two FKs back to journal_entries (closing_entry_id, opening_balance_entry_id), so the bare fiscal_periods(...) embed in resolveUnderlagAnchoring is ambiguous on prod and every lock-state read failed on the first underlag-reconcile cron run: 34 locked-period items were treated as retryable and hit the period-lock trigger daily. The embed now names journal_entries_fiscal_period_id_fkey (same hint the MCP server uses) and the exact select string is pinned in the unit test, since mocks cannot see PostgREST ambiguity. Follow-up to #2012 (issue #1548).
2026-08-31 10:16:21 +01:00

469 lines
19 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,
resolveUnderlagAnchoring,
} 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([])
})
})
describe('resolveUnderlagAnchoring', () => {
beforeEach(() => vi.clearAllMocks())
it('reads an item without a document as anchored, without querying', async () => {
// No underlag to carry onto the verifikat: only the stamp is missing.
const { supabase, calls } = createQueuedMockSupabase()
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
{ id: 'i1', document_id: null, journalEntryId: JE1 },
])
expect(map.get('i1')).toEqual({ status: 'anchored', document_journal_entry_id: null })
expect(calls.length).toBe(0)
})
it('classifies anchored, unlinked and anchored_elsewhere from one batched select', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: [
{ id: 'doc-a', journal_entry_id: JE1 },
{ id: 'doc-b', journal_entry_id: null },
{ id: 'doc-c', journal_entry_id: JE2 },
],
})
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
{ id: 'i-b', document_id: 'doc-b', journalEntryId: JE1 },
{ id: 'i-c', document_id: 'doc-c', journalEntryId: JE1 },
{ id: 'i-none', document_id: null, journalEntryId: JE1 },
])
expect(map.get('i-a')?.status).toBe('anchored')
expect(map.get('i-b')?.status).toBe('unlinked')
expect(map.get('i-c')).toEqual({ status: 'anchored_elsewhere', document_journal_entry_id: JE2 })
expect(map.get('i-none')?.status).toBe('anchored')
const selects = findCalls('document_attachments', 'select')
expect(selects.length).toBe(1)
expect(findCalls('document_attachments', 'in')).toEqual([['id', ['doc-a', 'doc-b', 'doc-c']]])
})
it('reads an unlinked item whose verifikat sits in a locked or closed period as unlinked_locked', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: [
{ id: 'doc-a', journal_entry_id: null },
{ id: 'doc-b', journal_entry_id: null },
{ id: 'doc-c', journal_entry_id: null },
{ id: 'doc-d', journal_entry_id: JE1 },
],
})
enqueue({
data: [
{ id: JE1, fiscal_period: { is_closed: false, locked_at: '2026-08-01T00:00:00Z' } },
{ id: JE2, fiscal_period: { is_closed: true, locked_at: null } },
{ id: 'je-open', fiscal_period: { is_closed: false, locked_at: null } },
],
})
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
{ id: 'i-b', document_id: 'doc-b', journalEntryId: JE2 },
{ id: 'i-c', document_id: 'doc-c', journalEntryId: 'je-open' },
{ id: 'i-d', document_id: 'doc-d', journalEntryId: JE1 },
])
expect(map.get('i-a')).toEqual({ status: 'unlinked_locked', document_journal_entry_id: null })
expect(map.get('i-b')).toEqual({ status: 'unlinked_locked', document_journal_entry_id: null })
expect(map.get('i-c')?.status).toBe('unlinked')
// Already anchored: the lock does not matter and it is not looked up.
expect(map.get('i-d')?.status).toBe('anchored')
expect(findCalls('journal_entries', 'in')).toEqual([['id', [JE1, JE2, 'je-open']]])
// The bare `fiscal_periods(...)` embed is ambiguous on prod (fiscal_periods
// has closing_entry_id and opening_balance_entry_id back to journal_entries)
// and made every lock-state read fail on the first cron run (2026-08-29).
expect(findCalls('journal_entries', 'select')).toEqual([
['id, fiscal_period:fiscal_periods!journal_entries_fiscal_period_id_fkey(is_closed, locked_at)'],
])
})
it('keeps an unlinked item unlinked (retryable) when the lock-state read fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'doc-a', journal_entry_id: null }] })
enqueue({ data: null, error: { message: 'boom' } })
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
])
expect(map.get('i-a')?.status).toBe('unlinked')
})
it('leaves items absent (unknown, not anchored) when the document read fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'boom' } })
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
{ id: 'i-none', document_id: null, journalEntryId: JE1 },
])
expect(map.has('i-a')).toBe(false)
expect(map.get('i-none')?.status).toBe('anchored')
})
it('leaves an item absent when its document row is not returned', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] })
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
])
expect(map.has('i-a')).toBe(false)
})
})