Files
accounted/lib/transactions/__tests__/inbox-underlag.test.ts
T
a4ceaafa4f feat(inbox): per-item underlag anchoring status and a daily reconcile cron for stranded underlag (#1548) (#2012)
* 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>
2026-08-28 17:45:10 +02:00

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)
})
})