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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ad8566f1ae
commit
a4ceaafa4f
@@ -0,0 +1,441 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// The reconciler orchestrates the shared underlag helpers; those are covered
|
||||
// in inbox-underlag.test.ts, so here they are mocked and the run's own
|
||||
// logic is what is under test: grouping, dry-run vs execute, classification,
|
||||
// the history trail, the scan cap, and never throwing.
|
||||
const resolveBooked = vi.fn()
|
||||
const resolveAnchoring = vi.fn()
|
||||
const propagate = vi.fn()
|
||||
vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
resolveBookedJournalEntryIds: (...a: unknown[]) => resolveBooked(...a),
|
||||
resolveUnderlagAnchoring: (...a: unknown[]) => resolveAnchoring(...a),
|
||||
propagateUnderlagForBookedTransaction: (...a: unknown[]) => propagate(...a),
|
||||
}))
|
||||
|
||||
const appendHistory = vi.fn()
|
||||
vi.mock('@/lib/processing-history/append', () => ({
|
||||
appendProcessingHistoryWithClient: (...a: unknown[]) => appendHistory(...a),
|
||||
}))
|
||||
|
||||
import {
|
||||
reconcileStrandedInboxUnderlag,
|
||||
INBOX_UNDERLAG_RECONCILED_EVENT,
|
||||
} from '../inbox-underlag-reconcile'
|
||||
|
||||
const C1 = 'company-1'
|
||||
const C2 = 'company-2'
|
||||
const TX1 = 'tx-1'
|
||||
const TX2 = 'tx-2'
|
||||
const TX3 = 'tx-3'
|
||||
const JE1 = 'je-1'
|
||||
const JE2 = 'je-2'
|
||||
|
||||
function item(id: string, company: string, tx: string, doc: string | null = `doc-${id}`) {
|
||||
return { id, company_id: company, matched_transaction_id: tx, document_id: doc }
|
||||
}
|
||||
|
||||
function anchoring(
|
||||
entries: Record<string, 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere'>,
|
||||
) {
|
||||
return new Map(
|
||||
Object.entries(entries).map(([id, status]) => [
|
||||
id,
|
||||
{ status, document_journal_entry_id: status === 'anchored_elsewhere' ? 'je-other' : null },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
propagate.mockResolvedValue(undefined)
|
||||
appendHistory.mockResolvedValue('event-1')
|
||||
})
|
||||
|
||||
describe('reconcileStrandedInboxUnderlag', () => {
|
||||
it('dry-run classifies without writing and counts stranded items', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C1, TX2), item('i3', C1, TX3)] })
|
||||
// TX3 is not booked: its item is scanned but not stranded.
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'unlinked', i2: 'anchored_elsewhere' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: false,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
execute: false,
|
||||
scanned: 3,
|
||||
truncated: false,
|
||||
strandedOnBooked: 2,
|
||||
repaired: 0,
|
||||
stillUnlinked: 1,
|
||||
anchoredElsewhere: 1,
|
||||
companiesTouched: 1,
|
||||
historyAppended: 0,
|
||||
failures: 0,
|
||||
})
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(1)
|
||||
expect(resolveAnchoring).toHaveBeenCalledWith(expect.anything(), C1, [
|
||||
{ id: 'i1', document_id: 'doc-i1', journalEntryId: JE1 },
|
||||
{ id: 'i2', document_id: 'doc-i2', journalEntryId: JE2 },
|
||||
])
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('execute propagates unlinked and anchored transactions but logs history only for repaired ones', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Two items on TX1 (a samlingsverifikat) and one on TX2.
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C1, TX1), item('i3', C1, TX2)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked', i2: 'anchored', i3: 'anchored' })) // before
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' })) // after, only the linked item is re-read
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
actorId: 'test-actor',
|
||||
})
|
||||
|
||||
// TX2's item was already anchored, so its link is settled, but the
|
||||
// propagation still runs for the legs only it covers (the transaction's
|
||||
// pinned document, the created_journal_entry_id stamp); only TX1's
|
||||
// unlinked item is re-read afterwards.
|
||||
expect(propagate).toHaveBeenCalledTimes(2)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX1, JE1)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX2, JE2)
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(2)
|
||||
expect(resolveAnchoring).toHaveBeenNthCalledWith(2, expect.anything(), C1, [
|
||||
{ id: 'i1', document_id: 'doc-i1', journalEntryId: JE1 },
|
||||
])
|
||||
expect(summary).toMatchObject({
|
||||
strandedOnBooked: 3,
|
||||
repaired: 1,
|
||||
alreadyAnchored: 2,
|
||||
stillUnlinked: 0,
|
||||
anchoredElsewhere: 0,
|
||||
historyAppended: 1,
|
||||
failures: 0,
|
||||
})
|
||||
// Only TX1 changed linkage (i1); TX2's item was already anchored, so no
|
||||
// changelog row pretends a repair happened there.
|
||||
expect(appendHistory).toHaveBeenCalledTimes(1)
|
||||
expect(appendHistory).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: C1,
|
||||
aggregateType: 'BankTransaction',
|
||||
aggregateId: TX1,
|
||||
correlationId: TX1,
|
||||
eventType: INBOX_UNDERLAG_RECONCILED_EVENT,
|
||||
payload: {
|
||||
transaction_id: TX1,
|
||||
journal_entry_id: JE1,
|
||||
inbox_item_ids: ['i1'],
|
||||
source: 'test-actor',
|
||||
},
|
||||
actor: { type: 'system', id: 'test-actor' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a link that failed again as still unlinked and appends nothing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 0, stillUnlinked: 1, historyAppended: 0 })
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('still unlinked'),
|
||||
expect.objectContaining({ inbox_item_id: 'i1', transaction_id: TX1 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a document anchored to another verifikat as a conflict, greppable by its verifikat', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
// Settled from the pre-state alone: no propagation, no after-read.
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'anchored_elsewhere' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 0, anchoredElsewhere: 1, historyAppended: 0 })
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(1)
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('another verifikat'),
|
||||
expect.objectContaining({ inbox_item_id: 'i1', document_journal_entry_id: 'je-other' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an item the anchoring read could not classify as unlinked, never repaired', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring.mockResolvedValue(new Map())
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 0, stillUnlinked: 1, historyAppended: 0 })
|
||||
})
|
||||
|
||||
it('groups per company and skips companies with no booked matches', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C2, TX2)] })
|
||||
resolveBooked.mockImplementation(async (_s: unknown, companyId: string) =>
|
||||
companyId === C1 ? new Map([[TX1, JE1]]) : new Map(),
|
||||
)
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(resolveBooked).toHaveBeenCalledWith(expect.anything(), C1, [TX1])
|
||||
expect(resolveBooked).toHaveBeenCalledWith(expect.anything(), C2, [TX2])
|
||||
expect(summary).toMatchObject({ scanned: 2, strandedOnBooked: 1, companiesTouched: 1, repaired: 1 })
|
||||
expect(propagate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads every candidate and spends maxItems on unlinked items only, deferring the rest', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
// Six candidates: two already anchored, one anchored elsewhere, three
|
||||
// unlinked. A read cap would have stopped at the first rows; the link
|
||||
// budget (2) must reach past the settled ones and defer only the third
|
||||
// unlinked item.
|
||||
enqueue({
|
||||
data: [
|
||||
item('a1', C1, TX1),
|
||||
item('a2', C1, TX1),
|
||||
item('e1', C1, TX2),
|
||||
item('u1', C1, TX3),
|
||||
item('u2', C2, 'tx-4'),
|
||||
item('u3', C2, 'tx-5'),
|
||||
],
|
||||
})
|
||||
resolveBooked.mockImplementation(async (_s: unknown, companyId: string) =>
|
||||
companyId === C1
|
||||
? new Map([[TX1, JE1], [TX2, JE2], [TX3, 'je-3']])
|
||||
: new Map([['tx-4', 'je-4'], ['tx-5', 'je-5']]),
|
||||
)
|
||||
resolveAnchoring.mockImplementation(async (_s: unknown, companyId: string) =>
|
||||
companyId === C1
|
||||
? anchoring({ a1: 'anchored', a2: 'anchored', e1: 'anchored_elsewhere', u1: 'unlinked' })
|
||||
: anchoring({ u2: 'unlinked', u3: 'unlinked' }),
|
||||
)
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
maxItems: 2,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(findCalls('invoice_inbox_items', 'range')).toEqual([[0, 999]])
|
||||
expect(summary).toMatchObject({
|
||||
scanned: 6,
|
||||
strandedOnBooked: 6,
|
||||
alreadyAnchored: 2,
|
||||
anchoredElsewhere: 1,
|
||||
deferred: 1,
|
||||
truncated: true,
|
||||
})
|
||||
// u1 and u2 got the budget; u3 waits for the next run, without a
|
||||
// misleading "still unlinked after re-run" line. TX1 (anchored items)
|
||||
// is propagated outside the budget for its pin and stamp legs; the
|
||||
// anchored-elsewhere conflict on TX2 is not.
|
||||
expect(propagate).toHaveBeenCalledTimes(3)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX1, JE1)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX3, 'je-3')
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C2, 'tx-4', 'je-4')
|
||||
expect(propagate).not.toHaveBeenCalledWith(expect.anything(), C1, TX2, JE2)
|
||||
expect(propagate).not.toHaveBeenCalledWith(expect.anything(), C2, 'tx-5', 'je-5')
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('link budget spent'),
|
||||
expect.objectContaining({ max_items: 2, deferred: 1 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('pages through more than one page of candidates', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
const firstPage = Array.from({ length: 1000 }, (_, i) => item(`p${i}`, C1, TX1))
|
||||
enqueue({ data: firstPage })
|
||||
enqueue({ data: [item('tail', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map())
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: false,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary.scanned).toBe(1001)
|
||||
expect(summary.truncated).toBe(false)
|
||||
expect(findCalls('invoice_inbox_items', 'range')).toEqual([[0, 999], [1000, 1999]])
|
||||
})
|
||||
|
||||
it('still propagates a transaction whose only item is anchored so an unlinked pin is repaired', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// i1's own document is anchored; i2 carries no document at all. Both
|
||||
// read 'anchored', but the transaction may still have a pinned document
|
||||
// (transactions.document_id) whose link failed earlier, and the items
|
||||
// still lack their stamp: only the propagation repairs those.
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C1, TX2, null)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'anchored', i2: 'anchored' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
maxItems: 0,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
// Outside the link budget (maxItems 0 still lets this run), no after-read
|
||||
// (the pre-state is the verdict) and no history: nothing this run can
|
||||
// vouch for changed the item's linkage.
|
||||
expect(propagate).toHaveBeenCalledTimes(2)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX1, JE1)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX2, JE2)
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(1)
|
||||
expect(summary).toMatchObject({
|
||||
strandedOnBooked: 2,
|
||||
repaired: 0,
|
||||
alreadyAnchored: 2,
|
||||
deferred: 0,
|
||||
truncated: false,
|
||||
historyAppended: 0,
|
||||
})
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a locked-period item separately, never propagates it, and appends nothing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'unlinked_locked' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
strandedOnBooked: 1,
|
||||
repaired: 0,
|
||||
stillUnlinked: 0,
|
||||
unlinkedLocked: 1,
|
||||
deferred: 0,
|
||||
truncated: false,
|
||||
historyAppended: 0,
|
||||
})
|
||||
// The link is known to fail (enforce_period_lock_documents): no
|
||||
// propagation, no budget spent, no daily "still unlinked" warning.
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('locked period'),
|
||||
expect.objectContaining({ inbox_item_id: 'i1' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not count an unreadable pre-state that reads anchored afterwards as repaired', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(new Map()) // before: document row unreadable
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' })) // after
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
// Unknown before is a reason to look (propagate), not evidence that this
|
||||
// run changed the linkage: no InboxUnderlagReconciled event.
|
||||
expect(propagate).toHaveBeenCalledTimes(1)
|
||||
expect(summary).toMatchObject({ repaired: 0, alreadyAnchored: 1, historyAppended: 0 })
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a zero summary with one failure when the scan errors, without throwing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'connection reset' } })
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ scanned: 0, strandedOnBooked: 0, repaired: 0, failures: 1 })
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a failing company and continues with the next one', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C2, TX2)] })
|
||||
resolveBooked.mockImplementation(async (_s: unknown, companyId: string) => {
|
||||
if (companyId === C1) throw new Error('resolver blew up')
|
||||
return new Map([[TX2, JE2]])
|
||||
})
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i2: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i2: 'anchored' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ failures: 1, companiesTouched: 1, repaired: 1 })
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C2, TX2, JE2)
|
||||
})
|
||||
|
||||
it('keeps the repair when the history append fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' }))
|
||||
appendHistory.mockRejectedValue(new Error('fk violation'))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 1, historyAppended: 0, failures: 0 })
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('processing_history append failed'),
|
||||
expect.objectContaining({ transaction_id: TX1 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
propagateUnderlagForBookedTransaction,
|
||||
resolveBookedJournalEntryIds,
|
||||
resolveUnderlagAnchoring,
|
||||
} from '../inbox-underlag'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
|
||||
@@ -356,3 +357,106 @@ describe('completeInboxItemsForBookedTransaction', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user