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
@@ -142,6 +142,11 @@ function reportUploadFailure(report: {
// ── Types ────────────────────────────────────────────────────
// Mirrors the extension's UnderlagStatus: the anchoring verdict, or 'unknown'
// when the server could not read the document row. Anything but 'anchored'
// keeps the item out of the booked bucket.
type UnderlagStatus = 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere' | 'unknown'
interface InboxItem {
id: string
// 'processing' is the staged-upload in-flight state: the row exists (the
@@ -170,6 +175,16 @@ interface InboxItem {
// samlingsverifikat only one of N items can carry the stamp; this field is
// what lets the rest read as booked. Absent on client-side placeholders.
matched_transaction_journal_entry_id?: string | null
// Whether THIS item's underlag reached that verifikat (#1548). The
// transaction being booked is a fact about the transaction, not about the
// item's document: one whose link failed ('unlinked', transient: the daily
// reconcile retries it), whose verifikat sits in a locked period
// ('unlinked_locked', unlock first), that sits on another verifikat
// ('anchored_elsewhere', a human decision), or that could not be read
// ('unknown') keeps the item in "Att göra". null when nothing was derived
// (no booked matched transaction, or the item is stamped). Absent on
// client-side placeholders.
underlag_status?: UnderlagStatus | null
error_message: string | null
// True when AI extraction was skipped: either because the upload caller
// passed skip_extraction=true (MCP/agent path) or because the server's
@@ -298,9 +313,31 @@ function countExtractedFields(data: InvoiceExtractionResult | null): number {
// else needs a first action.
type InboxStatus = 'needs_action' | 'processing' | 'linked' | 'booked' | 'error'
// A matched transaction that is booked while this item's own underlag is not
// on its verifikat: not "booked" for the inbox, and not bookable either (the
// book routes 409 on an already-booked transaction). The rail explains it
// instead of offering a bridge that can only fail.
function isUnderlagDivergent(item: InboxItem): boolean {
return (
!!item.matched_transaction_journal_entry_id &&
!!item.underlag_status &&
item.underlag_status !== 'anchored'
)
}
// One explanatory line per non-anchored status (#1548). 'unlinked' is the
// only one the daily reconcile can heal on its own; the others say what
// stands in the way instead of promising an automatic link.
const UNDERLAG_STATUS_MESSAGE_KEY: Record<Exclude<UnderlagStatus, 'anchored'>, string> = {
unlinked: 'underlag_unlinked',
unlinked_locked: 'underlag_unlinked_locked',
anchored_elsewhere: 'underlag_anchored_elsewhere',
unknown: 'underlag_unknown',
}
function deriveInboxStatus(item: InboxItem): InboxStatus {
if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked'
if (item.matched_transaction_journal_entry_id) return 'booked'
if (item.matched_transaction_journal_entry_id && !isUnderlagDivergent(item)) return 'booked'
// Staged upload mid-extraction. Outranks 'linked': a transaction-anchored
// upload is matched from birth, but offering the booking bridge before the
// fields exist would book from empty data. Transient (seconds): stays in
@@ -2701,14 +2738,22 @@ function FieldsRail({
}, [item.id])
const isProcessed = !!item.created_supplier_invoice_id
const underlagDivergent = isUnderlagDivergent(item)
// The verifikat this item resolved into: its own stamp, or the entry that
// anchors its matched (and already booked) transaction. See InboxItem.
// anchors its matched (and already booked) transaction, unless this item's
// own underlag never reached it. See InboxItem.
const bookedEntryId =
item.created_journal_entry_id ?? item.matched_transaction_journal_entry_id ?? null
item.created_journal_entry_id ??
(underlagDivergent ? null : item.matched_transaction_journal_entry_id) ??
null
const isBookedDirectly = !isProcessed && !!bookedEntryId
// "Resolved" now means a journal entry exists: matched_transaction_id alone
// is not resolved, it's the prerequisite for booking against that tx.
const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id
// The booking bridge (proposal, book/ask actions) only while the
// transaction is unbooked: a divergent item's transaction already has a
// verifikat, so booking would 409. It gets an explanation and a link.
const showBookingBridge = isLinkedToTransaction && !underlagDivergent
const isResolved = isProcessed || isBookedDirectly
// Staged upload mid-extraction: a real row whose deferred AI extraction has
// not landed yet. Same disabled treatment as the optimistic placeholder
@@ -2991,7 +3036,7 @@ function FieldsRail({
scrolling past nine values to reach the one thing to approve.
Suppressed while extraction is in flight: a proposal computed from
empty fields would be an invitation to book nothing. */}
{isLinkedToTransaction && !inFlight && (
{showBookingBridge && !inFlight && (
<ProposedBooking itemId={item.id} onLoaded={setProposal} />
)}
@@ -3089,10 +3134,29 @@ function FieldsRail({
</Link>
) : isLinkedToTransaction && item.matched_transaction_id ? (
<>
{/* The transaction is booked but this item's underlag is not on
its verifikat (#1548): say so, link the verifikat, and keep
"Avbryt matchning" as the way out. No booking bridge: the
book routes 409 on an already-booked transaction. */}
{underlagDivergent && item.matched_transaction_journal_entry_id && (
<AttnLine
className="pb-1"
action={{
label: t('underlag_open_verification'),
href: `/bookkeeping/${item.matched_transaction_journal_entry_id}`,
}}
>
{t(
UNDERLAG_STATUS_MESSAGE_KEY[
(item.underlag_status ?? 'unknown') as Exclude<UnderlagStatus, 'anchored'>
],
)}
</AttnLine>
)}
{/* Matched-to-tx state: show the bridge to booking. The user
picks one of two actions: book themselves with the
deterministic dialog, or hand off to the assistant. */}
{onAskAssistant && (
{showBookingBridge && onAskAssistant && (
<Button
variant="default"
size="sm"
@@ -3107,14 +3171,16 @@ function FieldsRail({
there is not, so there is no separate "book manually" path to
choose between. Nothing posts from here without the form's own
review step (convention 14). */}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setEditOpen(true)}
>
{proposal?.lines.length ? t('review_and_book') : t('book_manually')}
</Button>
{showBookingBridge && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setEditOpen(true)}
>
{proposal?.lines.length ? t('review_and_book') : t('book_manually')}
</Button>
)}
<button
type="button"
onClick={async () => {