Files
accounted/lib/transactions/inbox-underlag.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

434 lines
18 KiB
TypeScript

/**
* Inbox-underlag lifecycle for booked bank transactions.
*
* An invoice_inbox_items row leaves the active inbox ("Att göra") only when a
* journal entry consumes it: created_journal_entry_id (or
* created_supplier_invoice_id) is what deriveInboxStatus and the count pills
* read. Historically only categorizeTransactionCore stamped that column, so a
* matched item whose transaction was booked through any OTHER path (the /book
* route, bulk-book, link-to-existing-voucher, or an attach that landed after
* booking) stayed "linked" forever, pointing at a transaction that had left
* the transactions work list (the 2026-08-12 user report).
*
* This module is the single implementation all booking and attach paths share:
*
* - resolveBookedJournalEntryIds: which verifikat anchors each transaction,
* covering both direct journal_entry_id and the bulk-book
* transaction_voucher_links shape (see lib/transactions/is-booked.ts for
* why the column alone is not "booked").
* - propagateUnderlagForBookedTransaction: anchor the transaction's pinned
* document (transactions.document_id) and link matched items' documents to
* the verifikat (BFL 5 kap 6 §: the verifikation must reference its
* underlag), stamping created_journal_entry_id on the items. The pinned-doc
* leg matters because a document attached directly to a transaction has no
* inbox item to carry it: without it, booking through the manual dialog
* left document_attachments.journal_entry_id null and every underlag
* surface read "Underlag saknas" (the 2026-08-13 user report).
* - completeInboxItemsForBookedTransaction: the attach-time entry point that
* resolves first and propagates only when the transaction is booked.
*
* Everything here is best-effort by contract: the verifikat is already posted
* when these run, so a failure is logged and repaired by re-running, never
* allowed to roll back a compliant booking. Note that
* invoice_inbox_items.created_journal_entry_id is UNIQUE (migration
* 20260515090000): when several items share one samlingsverifikat only the
* first stamp can land, so the stamp is a fast path, not the source of truth:
* the inbox list ALSO derives "booked" from the matched transaction's state
* via resolveBookedJournalEntryIds (GET /items enrichment).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { createLogger } from '@/lib/logger'
const log = createLogger('transactions/inbox-underlag')
/** Postgres unique_violation: a sibling item already claimed this verifikat. */
const UNIQUE_VIOLATION = '23505'
/**
* Map each booked transaction id to the journal entry that anchors it:
* transactions.journal_entry_id first, then transaction_voucher_links
* (the N-tx-to-1-JE bulk-book shape). Unbooked transactions are absent
* from the returned map.
*
* The multi-allocation payment shape (invoice_payments /
* supplier_invoice_payments) is deliberately not resolved here: those flows
* consume inbox items through their own supplier-invoice lifecycle
* (created_supplier_invoice_id), not through this one.
*/
export async function resolveBookedJournalEntryIds(
supabase: SupabaseClient,
companyId: string,
txIds: string[],
): Promise<Map<string, string>> {
const map = new Map<string, string>()
if (txIds.length === 0) return map
const { data: txs, error: txError } = await supabase
.from('transactions')
.select('id, journal_entry_id')
.in('id', txIds)
.eq('company_id', companyId)
if (txError) {
log.error('Failed to resolve transactions for booked-entry lookup', {
company_id: companyId,
error: txError.message,
})
return map
}
const unbooked: string[] = []
for (const tx of (txs ?? []) as Array<{ id: string; journal_entry_id: string | null }>) {
if (tx.journal_entry_id) map.set(tx.id, tx.journal_entry_id)
else unbooked.push(tx.id)
}
if (unbooked.length === 0) return map
const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, unbooked)
for (const [txId, journalEntryId] of voucherLinked) {
if (!map.has(txId)) map.set(txId, journalEntryId)
}
return map
}
/**
* The transaction_voucher_links leg of the resolution alone: for callers that
* already hold transactions.journal_entry_id and only need the bulk-book
* fallback.
*/
export async function resolveVoucherLinkedEntryIds(
supabase: SupabaseClient,
companyId: string,
txIds: string[],
): Promise<Map<string, string>> {
const map = new Map<string, string>()
if (txIds.length === 0) return map
const { data: links, error: linkError } = await supabase
.from('transaction_voucher_links')
.select('transaction_id, journal_entry_id')
.in('transaction_id', txIds)
.eq('company_id', companyId)
if (linkError) {
log.error('Failed to resolve voucher links for booked-entry lookup', {
company_id: companyId,
error: linkError.message,
})
return map
}
for (const link of (links ?? []) as Array<{ transaction_id: string; journal_entry_id: string }>) {
if (!map.has(link.transaction_id)) map.set(link.transaction_id, link.journal_entry_id)
}
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
* no-op (a same-value rewrite would trip the period-lock trigger), a document
* anchored to ANOTHER verifikat is never stolen, and a failed link is
* reported so the caller can withhold any consumed-stamp. Returns true when
* the document ends up referencing the verifikat.
*/
async function anchorDocumentToJournalEntry(
supabase: SupabaseClient,
companyId: string,
documentId: string,
journalEntryId: string,
logContext: Record<string, unknown>,
): Promise<boolean> {
const { data: doc } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
const currentDocEntryId = (doc?.journal_entry_id as string | null) ?? null
if (currentDocEntryId === journalEntryId) return true
if (currentDocEntryId !== null) {
// Anchored to another verifikat: preserved, never stolen (BFL 5 kap 6-7 §).
log.warn('Document already anchored to another verifikat; leaving it', {
...logContext,
document_id: documentId,
document_journal_entry_id: currentDocEntryId,
journal_entry_id: journalEntryId,
})
return false
}
try {
await linkToJournalEntry(supabase, companyId, documentId, journalEntryId)
return true
} catch (err) {
log.error('Failed to link document to journal entry', {
...logContext,
document_id: documentId,
journal_entry_id: journalEntryId,
error: err instanceof Error ? err.message : String(err),
})
return false
}
}
/**
* Propagate the underlag onto the verifikat that booked a transaction.
* Without this, BFL 7 kap is violated: a verifikation exists with no underlag
* attached even though the user explicitly linked a document (or an inbox
* item with a document) to this transaction. We:
* 1. anchor the transaction's own pinned document (transactions.document_id)
* when it does not reference a verifikat yet: a document attached
* directly to the transaction has no inbox item, so nothing else carries
* it onto the verifikat
* 2. find the inbox item(s) where matched_transaction_id = txId that no
* journal entry or supplier invoice has consumed yet
* 3. for each item with a document_id, set
* document_attachments.journal_entry_id = journalEntryId, skipped when
* the document already points at a verifikat: a same-value rewrite would
* trip the period-lock trigger, and a different verifikat's underlag is
* never stolen
* 4. stamp invoice_inbox_items.created_journal_entry_id so the inbox row
* visibly moves to "Bokförda" and shows "Öppna verifikation"
* Errors are logged but never fail the caller: the verifikation itself is
* already posted, and the link can be repaired by re-running this step.
*/
export async function propagateUnderlagForBookedTransaction(
supabase: SupabaseClient,
companyId: string,
txId: string,
journalEntryId: string,
): Promise<void> {
try {
// The pin is read fresh here (not passed in from the caller's pre-booking
// snapshot) so an attach that lands concurrently with the booking is
// still anchored. The bulk-book RPC already anchors pins atomically;
// there this read finds the doc pointing at the same verifikat and no-ops.
const { data: tx } = await supabase
.from('transactions')
.select('document_id')
.eq('id', txId)
.eq('company_id', companyId)
.maybeSingle()
const pinnedDocumentId = (tx?.document_id as string | null) ?? null
if (pinnedDocumentId) {
await anchorDocumentToJournalEntry(supabase, companyId, pinnedDocumentId, journalEntryId, {
transaction_id: txId,
source: 'transaction_pin',
})
}
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('id, document_id')
.eq('company_id', companyId)
.eq('matched_transaction_id', txId)
.is('created_journal_entry_id', null)
.is('created_supplier_invoice_id', null)
for (const inbox of (matchedInboxItems ?? []) as Array<{
id: string
document_id: string | null
}>) {
// Whether this item's underlag actually references a verifikat. The
// stamp below is conditional on it: stamping after a FAILED document
// link would hide the item from every future run of this same query
// (.is('created_journal_entry_id', null)), making the promised
// "repaired by re-running" impossible and leaving a posted
// verifikation with no underlag reference (BFL 5 kap 6-7 §) that
// nothing surfaces anymore. Similarly, an item whose document is
// anchored to a DIFFERENT verifikat is not stamped: that would hide
// the very signal that the mismatch needs a human.
let underlagSettled = true
if (inbox.document_id) {
underlagSettled = await anchorDocumentToJournalEntry(
supabase,
companyId,
inbox.document_id,
journalEntryId,
{ inbox_item_id: inbox.id, source: 'inbox_match' },
)
}
if (!underlagSettled) continue
// CAS on the null predicate so a concurrent stamp stays a no-op, and
// unique_violation tolerated: on a samlingsverifikat only one item can
// hold the UNIQUE created_journal_entry_id, and the inbox list derives
// "booked" from the transaction's state for the rest.
const { error: stampError } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: journalEntryId })
.eq('id', inbox.id)
.eq('company_id', companyId)
.is('created_journal_entry_id', null)
if (stampError && stampError.code !== UNIQUE_VIOLATION) {
log.error('Failed to stamp inbox item created_journal_entry_id', {
inbox_item_id: inbox.id,
journal_entry_id: journalEntryId,
error: stampError.message,
})
}
}
} catch (err) {
log.error('Failed to propagate underlag from matched inbox items', err)
}
}
/**
* Attach-time entry point: when a document lands on (or an item is matched to)
* a transaction that is ALREADY booked, resolve the anchoring verifikat and
* complete the matched inbox items against it. No-op for unbooked
* transactions: the booking paths call propagateUnderlagForBookedTransaction
* themselves when the verifikat is created later.
*
* Callers that already read transactions.journal_entry_id pass it via
* `directJournalEntryId` (null meaning "the column is null") to skip the
* redundant transaction fetch; the voucher-link fallback still runs then.
*
* Returns the resolved journal entry id, or null when the transaction is not
* booked.
*/
export async function completeInboxItemsForBookedTransaction(
supabase: SupabaseClient,
companyId: string,
txId: string,
opts?: { directJournalEntryId: string | null },
): Promise<string | null> {
let journalEntryId: string | null
if (opts) {
journalEntryId =
opts.directJournalEntryId ??
(await resolveVoucherLinkedEntryIds(supabase, companyId, [txId])).get(txId) ??
null
} else {
journalEntryId =
(await resolveBookedJournalEntryIds(supabase, companyId, [txId])).get(txId) ?? null
}
if (!journalEntryId) return null
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
return journalEntryId
}