Files
accounted/lib/transactions/inbox-underlag.ts
T
MattssonandClaude Fable 5 8d56219c31 fix(inbox): booked items no longer strand in Att gora as matched-forever (#1547)
* fix(inbox): booked items no longer strand in Att gora as matched-forever

A matched inbox item only left the active inbox when
created_journal_entry_id was stamped, and only categorizeTransactionCore
stamped it. Booking the matched transaction through any other path (the
/book dialog route, bulk-book, link-to-existing-voucher) or matching a
receipt to an already-booked transaction (receipt hunt approvals,
attach-document, match-transaction) left the item "linked" forever,
pointing at a transaction that had already left the transactions work
list. Todays hunt fix (#1524) turned this July-old gap into a visible
flood of stuck items.

Two-part fix, because stamps alone cannot cover the reported case:
created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book
samlingsverifikat only one of N matched items can ever carry it.

Write side: lib/transactions/inbox-underlag.ts is the shared
implementation all paths now call. It links matched items' documents to
the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the
verifikation) and stamps created_journal_entry_id best-effort (CAS on
null, unique_violation tolerated). Wired into categorize-core (replacing
its inline block), /book, bulk-book, linkTransactionToJournalEntry, both
attach paths (REST + pending-operation), and the inbox match-transaction
handler. The attach paths and the doc-conflict guard also resolve
bulk-booked transactions through transaction_voucher_links, which they
previously treated as unbooked.

Read side: GET /items (and /items/:id) enrich matched-but-unstamped
items with matched_transaction_journal_entry_id, and the workspace
derives "booked" from it. This is what clears the stuck rows already in
prod without a status backfill, and what covers the N-1 samlingsverifikat
items the UNIQUE constraint refuses to stamp. Bulk-book selection
filters exclude such items so "Bokfor valda" no longer offers 409 fodder.

scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs
the historical document->verifikat links the old paths never made.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik

Both from the Swedish accounting compliance review.

The consumed-stamp is now conditional on the underlag actually
referencing a verifikat: stamping over a failed document link hid 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 kap) and nothing left to surface or repair it. A failed
link now leaves the item unstamped so re-runs and the backfill can
finish the job; a document preserved on another verifikat still counts
as settled.

The backfill script now appends an InboxUnderlagBackfilled event per
repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass
repair touching underlag-to-verifikat linkage leaves a changelog trail
distinguishing it from the original booking action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(inbox): backfill writes behandlingshistorik through the shared appender

From the Swedish accounting compliance review round 2: a hand-rolled
processing_history insert in the backfill script could drift from the
shared row shape and skip the PII validation. appendProcessingHistory
now delegates to appendProcessingHistoryWithClient, which takes a
caller-supplied service-role client, so standalone scripts write
behandlingshistorik through the exact same code path as the app
(BFNAR 2013:2 kap 8: one reconcilable change log across writers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(inbox): leave the item unstamped when its document belongs to another verifikat

Swedish accounting review round 3: refusing to steal the document was
right, but stamping the item consumed anyway hid the fact that the
transaction's own verifikat ended up with no underlag reference from it
(BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves
created_journal_entry_id null so the mismatch keeps surfacing for
reconciliation, same posture as a failed link.

Also documents in the backfill script header why its writes cannot land
in locked periods: linkToJournalEntry's UPDATE is guarded by the
enforce_period_lock DB trigger, which fires for service-role writes too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:49:20 +02:00

254 lines
11 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: link matched items' documents to
* the verifikat (BFL 5 kap 6 §: the verifikation must reference its
* underlag) and stamp created_journal_entry_id.
* - 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
}
/**
* Propagate the underlag from matched invoice-inbox items onto the verifikat
* that booked their transaction. Without this, BFL 7 kap is violated: a
* verifikation exists with no underlag attached even though the user
* explicitly linked an inbox item (with a document) to this transaction. We:
* 1. find the inbox item(s) where matched_transaction_id = txId that no
* journal entry or supplier invoice has consumed yet
* 2. 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
* 3. 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 {
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.
let underlagSettled = true
if (inbox.document_id) {
const { data: doc } = await supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('id', inbox.document_id)
.eq('company_id', companyId)
.maybeSingle()
const currentDocEntryId = (doc?.journal_entry_id as string | null) ?? null
if (currentDocEntryId === null) {
try {
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
} catch (err) {
underlagSettled = false
log.error('Failed to link inbox document to journal entry', {
inbox_item_id: inbox.id,
document_id: inbox.document_id,
journal_entry_id: journalEntryId,
error: err instanceof Error ? err.message : String(err),
})
}
} else if (currentDocEntryId !== journalEntryId) {
// Anchored to another verifikat: preserved, never stolen. But the
// verifikat that booked THIS transaction then has no underlag
// reference from this item, so it must NOT be stamped consumed:
// stamping would hide the very signal that the mismatch needs a
// human (BFL 5 kap 6-7 §).
underlagSettled = false
log.warn('Inbox document already anchored to another verifikat; leaving it', {
inbox_item_id: inbox.id,
document_id: inbox.document_id,
document_journal_entry_id: currentDocEntryId,
journal_entry_id: journalEntryId,
})
}
}
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
}