* feat(invoice-inbox): per-item underlag status and daily reconcile of stranded booked items (#1548) The inbox derives "booked" from the matched transaction's verifikat, but that says nothing about whether THIS item's document reached it: a link that failed at propagation time, or a document anchored to another verifikat, read as booked while the verifikat sat without its underlag (BFL 5 kap 6-7 §). GET /items and /items/:id now also emit underlag_status (anchored | unlinked | anchored_elsewhere) from one batched document_attachments read; the workspace keeps divergent items in "Att göra", drops the booking bridge for them (the book routes 409 on a booked transaction) and shows one explanatory line with a link to the verifikat. The backfill script's loop moves into lib/transactions/ inbox-underlag-reconcile.ts and runs daily from a new extension-owned cron (vercel.json plus the generated Docker crontabs): transient link failures heal without an ad-hoc script run, permanent conflicts are counted in one summary, and each repaired transaction leaves an InboxUnderlagReconciled row in behandlingshistorik. That event type is registered by migration 20260828154800: processing_history.event_type has an FK to processing_event_types, and the script's previous InboxUnderlagBackfilled type was never registered, so its appends had always failed silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(invoice-inbox): address review findings on the underlag reconcile (#1548) Findings 1, 3, 6 (scan cap starves the tail): the reconcile no longer caps the read. The matched-unconsumed candidate set holds permanent residents (samlingsverifikat siblings, anchored-elsewhere items) that never leave it, so a uuid-ordered read cap would revisit the same 1000 rows every night and never reach a stranded item sorting past the cut. The scan now pages through every candidate (four columns per row) and maxItems bounds the WORK: at most that many unlinked (or unreadable) items are propagated per run; already-anchored, anchored-elsewhere and locked items are counted from the pre-state without a propagation or budget. Items past the budget are counted as deferred and truncated is logged at warn level. Findings 2, 5 (false "linked automatically" promise for locked periods): resolveUnderlagAnchoring reads the fiscal period lock state of the verifikat for every unlinked item and reports unlinked_locked when is_closed or locked_at is set, the same pair enforce_period_lock_documents checks. The reconciler counts it separately (unlinkedLocked), never propagates it and never warns "still unlinked after re-run"; the rail shows a message that says the period must be unlocked first. Findings 4, 7 (absent anchoring read as booked): the list and detail enrichment emit underlag_status 'unknown' when the helper could not read the document row, and the workspace treats any status but 'anchored' as divergent (stays in Att göra, no booking bridge, own message). classify() counts a repair only when the pre-state was explicitly unlinked, so an unreadable before-read never earns an InboxUnderlagReconciled event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(invoice-inbox): address round-2 review findings (#1548) 1. [minor] Round-1 fix dropped propagation for transactions whose inbox items already read anchored, so the pinned-document leg (transactions.document_id) was never repaired and settled items never received their created_journal_entry_id stamp, staying in the scan and inflating alreadyAnchored every night. reconcileCompany now propagates every stranded transaction that has an unlinked (budgeted) item or an anchored / document-less item, outside the maxItems budget: the helper is idempotent and the stamp shrinks its own population. Locked-only and anchored-elsewhere-only transactions stay skipped. Counting and the behandlingshistorik trail are unchanged (anchored items keep their pre-state verdict, no event). Tests updated and a new case pins the anchored-item plus document-less-item transaction: propagated, no after-read, no history. DECISIONS line amended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
463 lines
18 KiB
TypeScript
463 lines
18 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']]])
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|