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>
This commit is contained in:
Mattsson
2026-08-13 00:49:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 402bf02b1b
commit 8d56219c31
17 changed files with 955 additions and 79 deletions
+1
View File
@@ -889,3 +889,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-12] Detail pager uses sessionStorage list-context + router.replace, not server neighbor queries: the kundfaktura list order is client-computed and not expressible in PostgREST.
[2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out.
[2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag.
[2026-08-12] Inbox "booked" state is derived server-side from the matched transaction (GET /items enrichment) in ADDITION to write-side created_journal_entry_id stamps, not stamps alone: created_journal_entry_id is UNIQUE (migration 20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry the stamp; a stamp-only fix could not clear the reported flood of matched items on bulk-booked transactions. The constraint stays (it guards the book-direct double-fire race); the stamp becomes a fast path and the derivation the source of truth.
@@ -107,6 +107,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
enqueue({ data: null, error: null }) // inbox-link best-effort update
enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked)
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
@@ -128,6 +129,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
enqueue({ data: null, error: null }) // inbox-link best-effort update
enqueue({ data: null, error: null }) // document_attachments propagation
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
@@ -145,6 +147,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
enqueue({ data: null, error: null }) // inbox-link best-effort update
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
@@ -159,6 +162,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
it('returns 409 when the document already belongs to a different verifikation', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
enqueue({ data: [], error: null }) // voucher-link check: je-OTHER anchors nothing here
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
@@ -168,6 +172,30 @@ describe('POST /api/transactions/[id]/attach-document', () => {
expect(body.error).toContain('annan verifikation')
})
it('completes the matched inbox item when the tx is anchored via a bulk-book samlingsverifikat', async () => {
// A bulk-booked tx keeps transactions.journal_entry_id null: the
// verifikat hangs off transaction_voucher_links. Attaching a receipt to
// it must link the underlag to that verifikat and stamp the matched
// inbox item, or the item strands as "linked" (the 2026-08-12 report).
const DOC = '11111111-1111-4111-8111-111111111111'
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch
enqueue({ data: { id: DOC, journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
enqueue({ data: null, error: null }) // inbox-link best-effort update
enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links
enqueue({ data: [{ id: 'inbox-1', document_id: DOC }], error: null }) // matched inbox items
enqueue({ data: { journal_entry_id: null }, error: null }) // doc anchor check: free
enqueue({ data: { id: 'je-9' }, error: null }) // linkToJournalEntry: JE ownership check
enqueue({ data: { id: DOC, journal_entry_id: 'je-9' }, error: null }) // linkToJournalEntry: doc update
enqueue({ data: null, error: null }) // created_journal_entry_id stamp
const res = await POST(makeReq({ document_id: DOC }), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(res)
expect(status).toBe(200)
// The response reports the samlingsverifikat the attach completed against.
expect(body.data.journal_entry_id).toBe('je-9')
})
it('returns 409 when the verifikation period is locked during propagation', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
@@ -219,6 +247,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
enqueue({ data: null, error: null }) // inbox-link update
enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked)
await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
@@ -234,6 +263,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails
enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await POST(
@@ -4,6 +4,10 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { AttachDocumentSchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import {
completeInboxItemsForBookedTransaction,
resolveVoucherLinkedEntryIds,
} from '@/lib/transactions/inbox-underlag'
ensureInitialized()
@@ -55,13 +59,20 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// A document that already serves as underlag for a DIFFERENT verifikation
// cannot be pinned here: propagating would either corrupt that link or be
// blocked by the document-metadata immutability trigger. Same verifikation
// is fine (idempotent re-attach; propagation below becomes a no-op).
// is fine (idempotent re-attach; propagation below becomes a no-op). A
// bulk-booked tx keeps journal_entry_id null and is anchored through
// transaction_voucher_links, so that anchoring counts as "same" too.
const docJournalEntryId = (document.journal_entry_id as string | null) ?? null
if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Underlaget är redan kopplat till en annan verifikation.' },
{ status: 409 },
)
const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, [
transactionId,
])
if (docJournalEntryId !== voucherLinked.get(transactionId)) {
return NextResponse.json(
{ error: 'Underlaget är redan kopplat till en annan verifikation.' },
{ status: 409 },
)
}
}
// Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we
@@ -152,6 +163,21 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
// The transaction may already be booked, directly or via a bulk-book
// samlingsverifikat (journal_entry_id null, anchored through
// transaction_voucher_links): complete the matched inbox items against
// the anchoring verifikat so an after-the-fact attach resolves them
// instead of stranding them as "linked" forever. Best-effort, logged
// inside. Returns the anchoring verifikat (the direct id when there is
// one), so the response and audit trail can report the voucher-linked
// case too.
const effectiveJournalEntryId = await completeInboxItemsForBookedTransaction(
supabase,
companyId,
transactionId,
{ directJournalEntryId: journalEntryId },
)
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
// was replaced. Best-effort: a logging failure must not roll back the
// (compliant) attach.
@@ -167,7 +193,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
transaction_id: transactionId,
previous_document_id: previousDocumentId,
new_document_id: document_id,
journal_entry_id: journalEntryId,
journal_entry_id: effectiveJournalEntryId,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
@@ -182,7 +208,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
transaction_id: transactionId,
document_id,
previous_document_id: previousDocumentId,
journal_entry_id: journalEntryId,
journal_entry_id: effectiveJournalEntryId,
},
})
},
+6
View File
@@ -7,6 +7,7 @@ import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { appendProcessingHistory } from '@/lib/processing-history/append'
@@ -181,6 +182,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
// A hunt- or hand-matched inbox item is consumed by this booking even
// though the dialog never saw it: link its underlag to the verifikat and
// stamp it so it leaves the active inbox (best-effort, logged inside).
await propagateUnderlagForBookedTransaction(supabase, companyId, id, journalEntry.id)
// Emit event (non-blocking)
try {
await eventBus.emit({
@@ -33,8 +33,16 @@ vi.mock('@/lib/bookkeeping/template-library', () => ({
applyTemplate: vi.fn(),
}))
// Stubbed so the queued supabase mock stays aligned with the route's own
// queries; the helper's behaviour is covered in
// lib/transactions/__tests__/inbox-underlag.test.ts.
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
import { POST } from '../route'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
const TX1 = '11111111-1111-4111-8111-111111111111'
const TX2 = '22222222-2222-4222-8222-222222222222'
@@ -114,6 +122,20 @@ describe('POST /api/transactions/bulk-book', () => {
expect(body.data.mode).toBe('link_existing')
expect(body.data.journal_entry_id).toBe(JE)
expect(body.data.linked_tx_count).toBe(2)
// Every booked tx gets its matched inbox items completed against the
// samlingsverifikat (underlag link + consumed stamp).
expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith(
expect.anything(),
'company-1',
TX1,
JE,
)
expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith(
expect.anything(),
'company-1',
TX2,
JE,
)
})
it('create-new path fetches template, expands per mode, and calls RPC', async () => {
+15
View File
@@ -11,6 +11,7 @@ import {
fetchActiveDimensionRules,
} from '@/lib/bookkeeping/dimension-rules'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
@@ -324,6 +325,20 @@ export const POST = withRouteContext(
return errorResponseFromCode(code, opLog, { requestId, details })
}
// Complete any matched inbox items against the samlingsverifikat: link
// their underlag and stamp them consumed so they leave the active inbox.
// Best-effort, logged inside; created_journal_entry_id is UNIQUE so at
// most one item can carry the stamp; the inbox list derives "booked"
// from the voucher links for the rest.
for (const txId of body.tx_ids) {
await propagateUnderlagForBookedTransaction(
supabase,
companyId!,
txId,
result.journal_entry_id,
)
}
// Emit one transaction.reconciled event per tx so existing subscribers
// (reminder cancellation, automation, processing-history) keep working.
// Best-effort; a failure here does not roll back the booking.
@@ -27,6 +27,9 @@ interface BulkBookInboxItem {
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
// Server-derived: the verifikat anchoring an already-booked matched
// transaction (see InvoiceInboxWorkspace's InboxItem).
matched_transaction_journal_entry_id?: string | null
extracted_data: InvoiceExtractionResult | null
}
@@ -85,7 +88,14 @@ const VAT_OPTIONS: { value: VatTreatment | 'auto'; label: string }[] = [
]
function isBookable(it: BulkBookInboxItem): boolean {
return Boolean(it.matched_transaction_id) && !it.created_journal_entry_id && !it.created_supplier_invoice_id
return (
Boolean(it.matched_transaction_id) &&
!it.created_journal_entry_id &&
!it.created_supplier_invoice_id &&
// A matched transaction that is already booked has nothing left to book:
// the server would only skip it as already_booked_or_duplicate.
!it.matched_transaction_journal_entry_id
)
}
export default function BulkBookInboxDialog({ open, onOpenChange, items, onSuccess }: Props) {
@@ -101,7 +111,13 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
[items],
)
const alreadyBooked = useMemo(
() => items.filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id).length,
() =>
items.filter(
(it) =>
it.created_journal_entry_id ||
it.created_supplier_invoice_id ||
it.matched_transaction_journal_entry_id,
).length,
[items],
)
@@ -95,6 +95,12 @@ interface InboxItem {
matched_transaction_id: string | null
created_supplier_invoice_id: string | null
created_journal_entry_id: string | null
// The verifikat that anchors the matched transaction when it is already
// booked (directly or via a bulk-book samlingsverifikat). Server-derived by
// GET /items: created_journal_entry_id is UNIQUE per verifikat, so on a
// 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
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
@@ -214,16 +220,18 @@ function countExtractedFields(data: InvoiceExtractionResult | null): number {
// Lifecycle stage of an inbox item. Single source of truth shared by the list
// filter, the count pills, and the row icons so they never drift apart.
//
// Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a
// direct journal entry) is done and drops out of the active inbox. A
// matched-but-unbooked item is "linked": it STAYS in the inbox as its own
// category because the bank payment still needs booking (a document attached
// to a transaction is not the same as a booked one). An extraction failure is
// "error"; everything else needs a first action.
// Precedence mirrors the FieldsRail: a booked item (supplier invoice, a
// direct journal entry, OR a matched transaction that is itself booked) is
// done and drops out of the active inbox. A matched-but-unbooked item is
// "linked": it STAYS in the inbox as its own category because the bank
// payment still needs booking (a document attached to a transaction is not
// the same as a booked one). An extraction failure is "error"; everything
// else needs a first action.
type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error'
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_id) return 'linked'
if (item.status === 'error') return 'error'
return 'needs_action'
@@ -976,7 +984,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const bookableSelectedCount = useMemo(
() =>
selectedItems.filter(
(it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id,
(it) =>
it.matched_transaction_id &&
!it.created_journal_entry_id &&
!it.created_supplier_invoice_id &&
// A matched transaction that is already booked has nothing left to
// bulk-book: the server would only skip it with a 409.
!it.matched_transaction_journal_entry_id,
).length,
[selectedItems],
)
@@ -2724,7 +2738,11 @@ function FieldsRail({
}, [item.id])
const isProcessed = !!item.created_supplier_invoice_id
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
// The verifikat this item resolved into: its own stamp, or the entry that
// anchors its matched (and already booked) transaction. See InboxItem.
const bookedEntryId =
item.created_journal_entry_id ?? 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
@@ -3059,8 +3077,8 @@ function FieldsRail({
Öppna leverantörsfaktura
</Button>
</Link>
) : isBookedDirectly && item.created_journal_entry_id ? (
<Link href={`/bookkeeping/${item.created_journal_entry_id}`} className="block">
) : isBookedDirectly && bookedEntryId ? (
<Link href={`/bookkeeping/${bookedEntryId}`} className="block">
<Button variant="default" size="sm" className="w-full">
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
Öppna verifikation
+75 -2
View File
@@ -57,6 +57,10 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas'
import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core'
import {
completeInboxItemsForBookedTransaction,
resolveBookedJournalEntryIds,
} from '@/lib/transactions/inbox-underlag'
import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
@@ -300,7 +304,44 @@ export const invoiceInboxExtension: Extension = {
const { data, error } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { items: data, count: data?.length ?? 0 } })
// Enrich matched-but-unstamped items with the verifikat that anchors
// their transaction, when it is already booked. The stamp
// (created_journal_entry_id) is UNIQUE per verifikat, so on a
// samlingsverifikat only one of N items can carry it: deriving
// "booked" from the transaction's own state is what lets the rest
// leave the active inbox (2026-08-12 report: booked items stuck in
// "Att göra" pointing at a transaction no longer in the work list).
type ItemRow = {
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
}
const rows = (data ?? []) as ItemRow[]
const unresolvedTxIds = Array.from(
new Set(
rows
.filter(
(r) =>
r.matched_transaction_id &&
!r.created_journal_entry_id &&
!r.created_supplier_invoice_id,
)
.map((r) => r.matched_transaction_id as string),
),
)
const bookedByTx = await resolveBookedJournalEntryIds(
ctx.supabase,
ctx.companyId,
unresolvedTxIds,
)
const items = rows.map((r) => ({
...r,
matched_transaction_journal_entry_id: r.matched_transaction_id
? bookedByTx.get(r.matched_transaction_id) ?? null
: null,
}))
return NextResponse.json({ data: { items, count: items.length } })
},
},
@@ -361,7 +402,28 @@ export const invoiceInboxExtension: Extension = {
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ data })
// Same enrichment as the list: the detail rail must not offer to
// book a matched transaction that is already booked.
const row = data as {
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
}
let matchedTransactionJournalEntryId: string | null = null
if (
row.matched_transaction_id &&
!row.created_journal_entry_id &&
!row.created_supplier_invoice_id
) {
const bookedByTx = await resolveBookedJournalEntryIds(ctx.supabase, ctx.companyId, [
row.matched_transaction_id,
])
matchedTransactionJournalEntryId = bookedByTx.get(row.matched_transaction_id) ?? null
}
return NextResponse.json({
data: { ...row, matched_transaction_journal_entry_id: matchedTransactionJournalEntryId },
})
},
},
@@ -846,6 +908,17 @@ export const invoiceInboxExtension: Extension = {
}
}
// The matched transaction may already be booked (directly or via a
// bulk-book samlingsverifikat): complete the item against the
// anchoring verifikat (underlag link + consumed stamp) so matching
// to a settled purchase resolves the item instead of stranding it
// as "linked". Best-effort, logged inside.
await completeInboxItemsForBookedTransaction(
ctx.supabase,
ctx.companyId,
body.transaction_id,
)
return NextResponse.json({ data: updated })
},
},
@@ -1004,6 +1004,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either
enqueue({ data: null, error: null }) // dispatcher commit update
const result = await commitPendingOperation(
@@ -1023,6 +1024,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // UPDATE returning post-state
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
enqueue({ data: null, error: null }) // doc propagation update
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
enqueue({ data: null, error: null }) // dispatcher commit update
const result = await commitPendingOperation(
@@ -1034,6 +1036,34 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
expect(result.status).toBe('committed')
})
it('completes matched inbox items when the tx is anchored via a bulk-book samlingsverifikat', async () => {
// A bulk-booked tx keeps transactions.journal_entry_id null: the verifikat
// hangs off transaction_voucher_links. Attaching a hunted receipt to it
// must still resolve the matched inbox item, or it strands as "linked"
// forever (the 2026-08-12 report).
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning (still null)
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links
enqueue({ data: [{ id: 'inbox-9', document_id: null }], error: null }) // matched inbox items
enqueue({ data: null, error: null }) // created_journal_entry_id stamp
enqueue({ data: null, error: null }) // dispatcher commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(baseOp),
)
expect(result.status).toBe('committed')
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: 'je-9' },
])
})
it('still commits when the inbox-link best-effort update errors', async () => {
// Inbox sync is best-effort: a failure to mark the inbox row as matched
// must not roll back the (compliant) doc→tx attach. Mirrors the REST
@@ -1046,6 +1076,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: null }, error: null }) // tx UPDATE returning
enqueue({ data: null, error: { message: 'inbox row missing or RLS-blocked' } }) // inbox link: errors
enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either
enqueue({ data: null, error: null }) // dispatcher commit update
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+29 -6
View File
@@ -69,6 +69,10 @@ import {
type BatchAllocationResult,
} from '@/lib/invoices/clear-settled-batch-allocations'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
import {
completeInboxItemsForBookedTransaction,
resolveVoucherLinkedEntryIds,
} from '@/lib/transactions/inbox-underlag'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
@@ -2942,13 +2946,18 @@ async function commitAttachDocumentToTransaction(
// A document that already serves as underlag for a DIFFERENT verifikation
// cannot be pinned here: propagating would either corrupt that link or be
// blocked by the document-metadata immutability trigger. Same verifikation
// is fine (idempotent re-attach; propagation below becomes a no-op).
// is fine (idempotent re-attach; propagation below becomes a no-op). A
// bulk-booked tx keeps journal_entry_id null and is anchored through
// transaction_voucher_links, so that anchoring counts as "same" too.
// Mirrors the REST route in app/api/transactions/[id]/attach-document.
const docJournalEntryId = (doc.journal_entry_id as string | null) ?? null
if (docJournalEntryId && docJournalEntryId !== tx.journal_entry_id) {
return {
error: 'Underlaget är redan kopplat till en annan verifikation.',
status: 409,
const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, [txId])
if (docJournalEntryId !== voucherLinked.get(txId)) {
return {
error: 'Underlaget är redan kopplat till en annan verifikation.',
status: 409,
}
}
}
@@ -3042,6 +3051,20 @@ async function commitAttachDocumentToTransaction(
}
}
// The transaction may already be booked, directly or via a bulk-book
// samlingsverifikat (journal_entry_id null, anchored through
// transaction_voucher_links): complete the matched inbox items against the
// anchoring verifikat so an after-the-fact attach resolves them instead of
// stranding them as "linked" forever. Best-effort, logged inside. Returns
// the anchoring verifikat (the direct id when there is one), so the result
// and audit trail can report the voucher-linked case too.
const effectiveJournalEntryId = await completeInboxItemsForBookedTransaction(
supabase,
companyId,
txId,
{ directJournalEntryId: journalEntryId },
)
// Rättelse audit trail (BFL 5 kap 5 §): if we replaced a non-null doc, log
// the swap to processing_history so the original is traceable. Best-effort:
// a logging failure must not roll back the (compliant) attach.
@@ -3057,7 +3080,7 @@ async function commitAttachDocumentToTransaction(
transaction_id: txId,
previous_document_id: previousDocumentId,
new_document_id: documentId,
journal_entry_id: journalEntryId,
journal_entry_id: effectiveJournalEntryId,
},
actor: { type: 'user', id: userId },
occurredAt: new Date(),
@@ -3072,7 +3095,7 @@ async function commitAttachDocumentToTransaction(
transaction_id: txId,
document_id: documentId,
previous_document_id: previousDocumentId,
journal_entry_id: journalEntryId,
journal_entry_id: effectiveJournalEntryId,
},
}
}
+23 -1
View File
@@ -18,9 +18,17 @@ import type {
ProcessingHistoryAggregateType,
ProcessingHistoryActor,
} from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClient } from '@/lib/supabase/server'
import { z } from 'zod'
/**
* Any service-role client with the query surface the append needs. Structural
* so both the Next-bound createServiceClient() and a script's own
* createClient(url, serviceRoleKey) satisfy it.
*/
type SupabaseClientLike = Pick<SupabaseClient, 'from'>
// ── PII validator ───────────────────────────────────────────────
// Rejects payloads containing Swedish personal identity numbers.
// Personnummer: YYMMDD-NNNN or YYMMDDNNNN (6+4 digits)
@@ -101,13 +109,27 @@ export interface AppendEventInput {
*/
export async function appendProcessingHistory(
input: AppendEventInput
): Promise<string> {
return appendProcessingHistoryWithClient(createServiceClient(), input)
}
/**
* Same append, on a caller-supplied service-role client. For standalone
* scripts (e.g. scripts/backfill-inbox-booked-underlag.ts) that cannot build
* the Next-bound service client but must still write behandlingshistorik
* through the one shared row shape and PII validation (BFNAR 2013:2 kap 8:
* the change log has to reconcile across writers, so scripts never hand-roll
* the insert).
*/
export async function appendProcessingHistoryWithClient(
supabase: SupabaseClientLike,
input: AppendEventInput
): Promise<string> {
// Validate payload + actor.label contain no PII
piiSafePayload.parse(input.payload)
assertActorPiiSafe(input.actor)
const eventId = crypto.randomUUID()
const supabase = createServiceClient()
const { error } = await supabase
.from('processing_history')
@@ -0,0 +1,230 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: vi.fn().mockResolvedValue({}),
}))
import {
completeInboxItemsForBookedTransaction,
propagateUnderlagForBookedTransaction,
resolveBookedJournalEntryIds,
} from '../inbox-underlag'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
const COMPANY = 'company-1'
const TX1 = 'tx-1'
const TX2 = 'tx-2'
const JE1 = 'je-1'
const JE2 = 'je-2'
describe('resolveBookedJournalEntryIds', () => {
beforeEach(() => vi.clearAllMocks())
it('returns an empty map without querying for an empty id list', async () => {
const { supabase, calls } = createQueuedMockSupabase()
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [])
expect(map.size).toBe(0)
expect(calls.length).toBe(0)
})
it('resolves direct journal_entry_id and falls back to voucher links', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{ id: TX1, journal_entry_id: JE1 },
{ id: TX2, journal_entry_id: null },
],
})
// Voucher-link fallback runs only for the unbooked remainder (TX2).
enqueue({ data: [{ transaction_id: TX2, journal_entry_id: JE2 }] })
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [
TX1,
TX2,
])
expect(map.get(TX1)).toBe(JE1)
expect(map.get(TX2)).toBe(JE2)
})
it('omits transactions booked by neither route', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: TX1, journal_entry_id: null }] })
enqueue({ data: [] }) // no voucher links either
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [
TX1,
])
expect(map.size).toBe(0)
})
})
describe('propagateUnderlagForBookedTransaction', () => {
beforeEach(() => vi.clearAllMocks())
it('links the document and stamps the matched item', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] }) // matched items
enqueue({ data: { journal_entry_id: null } }) // doc not yet anchored
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-1', JE1)
const stamps = findCalls('invoice_inbox_items', 'update')
expect(stamps).toContainEqual([{ created_journal_entry_id: JE1 }])
})
it('skips the document write when it already points at the verifikat', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored to JE1
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
it('never steals a document anchored to another verifikat, and does not stamp', async () => {
// The doc stays on its verifikat, but THIS transaction's verifikat then
// has no underlag from the item: stamping it consumed would hide the
// mismatch from every future run and from manual reconciliation
// (BFL 5 kap 6-7 §).
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: 'je-other' } })
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
it('stamps an item without a document (no document read)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'i1', document_id: null }] })
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
expect(findCalls('document_attachments', 'select').length).toBe(0)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
it('tolerates the UNIQUE violation when a sibling item already claimed the verifikat', async () => {
// Samlingsverifikat: created_journal_entry_id is UNIQUE, so on N matched
// items only the first stamp lands. The rest must resolve quietly: the
// inbox list derives "booked" from the transaction's state for them.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'i1', document_id: null }] })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } })
await expect(
propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
),
).resolves.toBeUndefined()
})
it('does NOT stamp when the document link fails, so a re-run can still repair it', async () => {
// Stamping over a failed link would hide 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 §)
// and nothing left to surface or repair it.
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: null } })
await expect(
propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
),
).resolves.toBeUndefined()
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
})
describe('completeInboxItemsForBookedTransaction', () => {
beforeEach(() => vi.clearAllMocks())
it('is a no-op for an unbooked transaction', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: TX1, journal_entry_id: null }] })
enqueue({ data: [] }) // no voucher links
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
)
expect(result).toBeNull()
expect(findCalls('invoice_inbox_items', 'select').length).toBe(0)
})
it('skips the transaction fetch when the caller passes the direct id', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] }) // matched items (none)
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
{ directJournalEntryId: JE1 },
)
expect(result).toBe(JE1)
expect(findCalls('transactions', 'select').length).toBe(0)
})
it('resolves the samlingsverifikat through voucher links when the direct id is null', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ transaction_id: TX1, journal_entry_id: JE2 }] }) // voucher links
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched items
enqueue({ data: null }) // stamp update
const result = await completeInboxItemsForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
{ directJournalEntryId: null },
)
expect(result).toBe(JE2)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE2 },
])
})
})
+6 -51
View File
@@ -29,7 +29,6 @@ import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
import {
detectBookingDuplicate,
@@ -37,6 +36,7 @@ import {
type BookingDuplicateExclusions,
} from '@/lib/transactions/booking-duplicate-detection'
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { createLogger } from '@/lib/logger'
import type { InboxChannelContext, Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
@@ -375,57 +375,12 @@ export async function categorizeMatchedTransaction(
return { error: 'Failed to update transaction', status: 500 }
}
// Propagate the underlag from a matched invoice-inbox item onto the new
// verifikation. 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
// 2. for each item with a document_id, set
// document_attachments.journal_entry_id = journalEntryId (idempotent)
// 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox row
// visibly moves to "Bearbetade" and shows "Öppna verifikation".
// Errors are logged but don't fail the commit: the verifikation itself is
// already posted, and the link can be repaired by re-running this step.
// Propagate the underlag from matched invoice-inbox items onto the new
// verifikation and stamp them consumed (BFL 7 kap): shared with the other
// booking paths, see lib/transactions/inbox-underlag.ts. Best-effort: the
// verifikation is already posted, so a failure is logged, never fatal.
if (journalEntryId) {
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)
for (const inbox of (matchedInboxItems ?? []) as Array<{
id: string
document_id: string | null
}>) {
if (inbox.document_id) {
try {
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
} catch (err) {
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),
})
}
}
const { error: stampError } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: journalEntryId })
.eq('id', inbox.id)
.eq('company_id', companyId)
if (stampError) {
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)
}
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
}
try {
+253
View File
@@ -0,0 +1,253 @@
/**
* 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
}
+6
View File
@@ -18,6 +18,7 @@ import { eventBus } from '@/lib/events/bus'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { createLogger } from '@/lib/logger'
import type { Invoice, Transaction } from '@/types'
@@ -415,6 +416,11 @@ export async function linkTransactionToJournalEntry(
}
}
// The transaction is now anchored to an existing verifikat: complete any
// matched inbox items against it (underlag link + consumed stamp) so they
// leave the active inbox. Best-effort, logged inside.
await propagateUnderlagForBookedTransaction(supabase, companyId, transactionId, journalEntryId)
logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
invoiceId,
newState: {
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env npx tsx
/**
* Backfill for inbox items stranded as "linked" on an already-booked
* transaction (2026-08-12 report: booked verifikat stuck in "Att göra").
*
* The inbox list now derives "booked" from the matched transaction's own
* state, so the DISPLAY heals by itself. What this script repairs is the
* data left behind by the old write paths:
*
* - document_attachments.journal_entry_id: the underlag of a matched item
* was never linked to the verifikat that booked its transaction
* (BFL 5 kap 6 §). Linked here unless the document is already anchored.
* - invoice_inbox_items.created_journal_entry_id: stamped where the UNIQUE
* constraint allows (on a samlingsverifikat only one of N items can
* carry it; the rest stay derived).
*
* Idempotent: items already stamped are excluded by the query, and the
* propagation helper skips documents that are already anchored.
*
* Usage:
* npx tsx scripts/backfill-inbox-booked-underlag.ts # dry-run (default)
* npx tsx scripts/backfill-inbox-booked-underlag.ts --execute # apply
*
* DRY-RUN IS THE DEFAULT. Point NEXT_PUBLIC_SUPABASE_URL /
* SUPABASE_SERVICE_ROLE_KEY (.env.local) at staging first; prod only after
* explicit confirmation.
*
* Period locks: the document link goes through linkToJournalEntry, whose
* document_attachments.journal_entry_id UPDATE is guarded by the
* enforce_period_lock DB trigger. Table triggers fire for service-role
* writes too (service role bypasses RLS, never triggers), so a locked
* period fails that item with a logged error instead of writing into it.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { appendProcessingHistoryWithClient } from '@/lib/processing-history/append'
import {
propagateUnderlagForBookedTransaction,
resolveBookedJournalEntryIds,
} from '@/lib/transactions/inbox-underlag'
const EXECUTE = process.argv.includes('--execute')
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(supabaseUrl, serviceRoleKey)
interface StrandedItem {
id: string
company_id: string
matched_transaction_id: string
document_id: string | null
}
async function main() {
console.log(`Target: ${supabaseUrl}`)
console.log(EXECUTE ? 'MODE: EXECUTE (writing)' : 'MODE: dry-run (no writes)')
const items = await fetchAllRows<StrandedItem>((range) =>
supabase
.from('invoice_inbox_items')
.select('id, company_id, matched_transaction_id, document_id')
.not('matched_transaction_id', 'is', null)
.is('created_journal_entry_id', null)
.is('created_supplier_invoice_id', null)
.order('id', { ascending: true })
.range(range.from, range.to),
)
console.log(`Matched, unconsumed inbox items: ${items.length}`)
// Group per company so the resolver runs one batched lookup per tenant.
const byCompany = new Map<string, StrandedItem[]>()
for (const item of items) {
const list = byCompany.get(item.company_id) ?? []
list.push(item)
byCompany.set(item.company_id, list)
}
let strandedOnBooked = 0
let companiesTouched = 0
for (const [companyId, companyItems] of byCompany) {
const txIds = Array.from(new Set(companyItems.map((i) => i.matched_transaction_id)))
const bookedByTx = await resolveBookedJournalEntryIds(supabase, companyId, txIds)
const stranded = companyItems.filter((i) => bookedByTx.has(i.matched_transaction_id))
if (stranded.length === 0) continue
companiesTouched++
strandedOnBooked += stranded.length
console.log(`company ${companyId}: ${stranded.length} item(s) on booked transactions`)
if (!EXECUTE) continue
const txIdsToComplete = Array.from(new Set(stranded.map((i) => i.matched_transaction_id)))
for (const txId of txIdsToComplete) {
const journalEntryId = bookedByTx.get(txId)
if (!journalEntryId) continue
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
// Behandlingshistorik (BFNAR 2013:2 kap 8): a mass repair touching
// underlag-to-verifikat linkage must leave a changelog trail
// distinguishing it from the original booking action. Written through
// the shared appender (row shape + PII validation) on this script's
// own service-role client; payload is pseudonymous IDs only.
const itemIds = stranded
.filter((i) => i.matched_transaction_id === txId)
.map((i) => i.id)
try {
await appendProcessingHistoryWithClient(supabase, {
companyId,
correlationId: txId,
aggregateType: 'BankTransaction',
aggregateId: txId,
eventType: 'InboxUnderlagBackfilled',
payload: {
transaction_id: txId,
journal_entry_id: journalEntryId,
inbox_item_ids: itemIds,
script: 'backfill-inbox-booked-underlag',
},
actor: { type: 'system', id: 'backfill-inbox-booked-underlag' },
occurredAt: new Date(),
})
} catch (historyError) {
console.error(
`processing_history append failed for tx ${txId}: ${
historyError instanceof Error ? historyError.message : String(historyError)
}`,
)
}
}
}
console.log(
`${EXECUTE ? 'Repaired' : 'Would repair'} ${strandedOnBooked} item(s) across ${companiesTouched} company/companies.`,
)
if (!EXECUTE) console.log('Re-run with --execute to apply.')
}
main().catch((err) => {
console.error(err)
process.exit(1)
})