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:
Jakob Wennberg
2026-08-28 17:45:10 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent ad8566f1ae
commit a4ceaafa4f
18 changed files with 1727 additions and 116 deletions
+130
View File
@@ -120,6 +120,136 @@ export async function resolveVoucherLinkedEntryIds(
return map
}
/**
* Whether an inbox item's underlag actually references the verifikat that
* booked its transaction. Both readers of "this item is booked" need it:
*
* - the inbox list enrichment, so an item whose document never reached the
* verifikat (a failed link, or a document anchored to a DIFFERENT
* verifikat) keeps showing in "Att göra" instead of reading as booked on
* the transaction's word alone (#1548)
* - the reconciliation pass, to classify what a re-run repaired and what
* still needs a human
*
* 'anchored' : document_attachments.journal_entry_id equals the
* verifikat, or the item carries no document (there
* is no underlag to link; the stamp is all that is
* missing)
* 'unlinked' : the document references no verifikat yet
* (transient: a re-run of the propagation links it)
* 'unlinked_locked' : unlinked, and the verifikat sits in a closed or
* locked period, so enforce_period_lock_documents
* rejects the link until someone unlocks the period
* (not transient: a re-run fails the same way)
* 'anchored_elsewhere' : the document references another verifikat
* (permanent: never stolen, a human decides)
*
* One batched select for N items, plus one lock-state read for the verifikat
* of every unlinked item. Items whose document row cannot be read (select
* error) are absent from the map: callers treat absence as unknown, never as
* anchored. A failed lock-state read leaves the item 'unlinked' (the
* propagation is what fails safely, so erring towards "retry" is harmless).
*/
export type UnderlagAnchoring = 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere'
export interface UnderlagAnchoringResult {
status: UnderlagAnchoring
/** The verifikat the document currently references, if any. */
document_journal_entry_id: string | null
}
export async function resolveUnderlagAnchoring(
supabase: SupabaseClient,
companyId: string,
items: Array<{ id: string; document_id: string | null; journalEntryId: string }>,
): Promise<Map<string, UnderlagAnchoringResult>> {
const map = new Map<string, UnderlagAnchoringResult>()
const withDocument: typeof items = []
for (const item of items) {
if (item.document_id) withDocument.push(item)
else map.set(item.id, { status: 'anchored', document_journal_entry_id: null })
}
if (withDocument.length === 0) return map
const docIds = Array.from(new Set(withDocument.map((i) => i.document_id as string)))
const { data: docs, error } = await supabase
.from('document_attachments')
.select('id, journal_entry_id')
.in('id', docIds)
.eq('company_id', companyId)
if (error) {
log.error('Failed to resolve document anchoring for inbox items', {
company_id: companyId,
error: error.message,
})
return map
}
const entryByDoc = new Map<string, string | null>()
for (const doc of (docs ?? []) as Array<{ id: string; journal_entry_id: string | null }>) {
entryByDoc.set(doc.id, doc.journal_entry_id)
}
const unlinked: typeof items = []
for (const item of withDocument) {
const docId = item.document_id as string
if (!entryByDoc.has(docId)) continue // unreadable row: unknown, not anchored
const current = entryByDoc.get(docId) ?? null
const status: UnderlagAnchoring =
current === null
? 'unlinked'
: current === item.journalEntryId
? 'anchored'
: 'anchored_elsewhere'
if (status === 'unlinked') unlinked.push(item)
map.set(item.id, { status, document_journal_entry_id: current })
}
if (unlinked.length === 0) return map
const lockedEntryIds = await resolveLockedJournalEntryIds(
supabase,
companyId,
Array.from(new Set(unlinked.map((i) => i.journalEntryId))),
)
for (const item of unlinked) {
if (lockedEntryIds.has(item.journalEntryId)) {
map.set(item.id, { status: 'unlinked_locked', document_journal_entry_id: null })
}
}
return map
}
/**
* Which of the given verifikat sit in a closed or locked fiscal period: the
* same (is_closed, locked_at) pair enforce_period_lock_documents checks, so
* a document link to them is known to fail before it is attempted. A read
* error yields an empty set (nothing is reported as locked on a guess).
*/
async function resolveLockedJournalEntryIds(
supabase: SupabaseClient,
companyId: string,
entryIds: string[],
): Promise<Set<string>> {
const locked = new Set<string>()
if (entryIds.length === 0) return locked
const { data, error } = await supabase
.from('journal_entries')
.select('id, fiscal_period:fiscal_periods(is_closed, locked_at)')
.in('id', entryIds)
.eq('company_id', companyId)
if (error) {
log.error('Failed to resolve period lock state for inbox underlag anchoring', {
company_id: companyId,
error: error.message,
})
return locked
}
type PeriodLock = { is_closed?: boolean | null; locked_at?: string | null }
for (const row of (data ?? []) as Array<{ id: string; fiscal_period: PeriodLock | PeriodLock[] | null }>) {
const period = Array.isArray(row.fiscal_period) ? row.fiscal_period[0] : row.fiscal_period
if (period?.is_closed || period?.locked_at) locked.add(row.id)
}
return locked
}
/**
* Anchor one document to the verifikat, with the guard semantics every
* booking path shares: a document already pointing at THIS verifikat is a