fix(transactions): anchor the pinned document to the verifikat on booking (#1560)

A document pinned to a transaction (transactions.document_id) with no
unconsumed inbox item was never anchored onto the verifikat when the
transaction was booked: document_attachments.journal_entry_id stayed
null and every underlag surface reported "Underlag saknas" for a
booking that HAS its underlag (attach-before-book via the manual
booking dialog, the 2026-08-13 user report).

PR #1547 already routed /book, bulk-book and categorize through the
shared propagateUnderlagForBookedTransaction helper, but that helper
only walked matched inbox items. This adds a pinned-document leg to the
helper, so all booking paths anchor the pin in one place:

- the pin is read fresh inside the helper (not from the caller's
  pre-booking snapshot) so a concurrent attach is still anchored
- same guard semantics as inbox docs, via the extracted
  anchorDocumentToJournalEntry: no-op when already anchored to this
  verifikat, never steal another verifikat's underlag, log-and-continue
  on failure (the booking is already posted; a re-run repairs the link)
- the bulk-book RPC already anchors pins atomically, so the leg no-ops
  there

Route tests cover the three plan cases: pinned doc anchored, matched
inbox item stamped, and propagation failure never failing the booking.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 11:50:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 314efe8b22
commit d9dddba682
6 changed files with 287 additions and 48 deletions
+1
View File
@@ -896,3 +896,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] ChannelQuestionAsked/Answered/Expired registered in processing_event_types rather than dropping the appends: appendQuestionHistory swallows the FK violation by design so the WhatsApp reply still goes out, which turned a missing catalog row into a per-question production error nobody saw, and the question exchange is part of how the underlag was obtained (BFNAR 2013:2 kap 8).
[2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there.
[2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server.
[2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there.
@@ -10,7 +10,7 @@ import {
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -251,6 +251,85 @@ describe('POST /api/transactions/[id]/book', () => {
expect(body.error).toBe('Failed to update transaction')
})
// ── Underlag propagation (pinned document + matched inbox items) ──────
// Attach-before-book: a document pinned to the transaction (or an inbox
// item hand-matched to it) must land on the new verifikat, or every
// underlag surface reads "Underlag saknas" for a booking that HAS its
// underlag (the 2026-08-13 user report).
it('anchors the pinned document to the new verifikat (attach-before-book)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch transaction
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: { document_id: 'doc-1' } }) // propagate: tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc unanchored
enqueue({ data: { id: 'je-new' } }) // linkToJournalEntry: entry ownership check
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-new' } }) // doc update
enqueue({ data: [] }) // no matched inbox items
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(findCalls('document_attachments', 'update')).toContainEqual([
{ journal_entry_id: 'je-new', journal_entry_line_id: null },
])
})
it('stamps a matched inbox item consumed by the booking', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch transaction
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: { document_id: null } }) // propagate: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched inbox item
enqueue({ data: null }) // stamp update
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: 'je-new' },
])
})
it('still returns success when underlag propagation fails (best-effort)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch transaction
mockCreateJournalEntry.mockResolvedValue(je)
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: { document_id: 'doc-1' } }) // propagate: tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc unanchored
enqueue({ data: null }) // linkToJournalEntry: entry lookup fails -> throws
enqueue({ data: [] }) // no matched inbox items
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
// The verifikat is already posted: a propagation failure is logged and
// repaired by re-running, never allowed to fail the booking.
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(findCalls('document_attachments', 'update')).toEqual([])
})
// ── Booking-time duplicate guard ──────────────────────────────────────
it('returns 409 duplicate warning when a booked sibling shares date+amount', async () => {
@@ -1024,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: { document_id: null }, error: null }) // completion: tx pin lookup
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
enqueue({ data: null, error: null }) // dispatcher commit update
@@ -1048,6 +1049,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
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: { document_id: null }, error: null }) // propagation: tx pin lookup
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
@@ -519,14 +519,15 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading',
})
describe('bulkBookMatchedInboxItems: intra-batch duplicate handling', () => {
/** Six queued from() results for one successfully-booked item. */
/** Seven queued from() results for one successfully-booked item. */
const bookableItem = (itemId: string, txId: string, amount: number) => [
{ data: { id: itemId, matched_transaction_id: txId, created_journal_entry_id: null, created_supplier_invoice_id: null } },
{ data: { id: txId, date: '2026-06-01', amount, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [{ id: 'fp-1' }] },
{ error: null },
{ data: [] },
{ data: { document_id: null } }, // propagation: tx pin lookup
{ data: [] }, // propagation: matched inbox items
]
it('books BOTH distinct transactions that share (date, amount) in one bulk run', async () => {
@@ -65,6 +65,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
it('links the document and stamps the matched item', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
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
@@ -83,6 +84,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
it('skips the document write when it already points at the verifikat', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored to JE1
enqueue({ data: null }) // stamp update
@@ -106,6 +108,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
// mismatch from every future run and from manual reconciliation
// (BFL 5 kap 6-7 §).
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: 'je-other' } })
@@ -122,6 +125,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
it('stamps an item without a document (no document read)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: null }] })
enqueue({ data: null }) // stamp update
@@ -144,6 +148,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
// 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: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: null }] })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } })
@@ -164,6 +169,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
// and nothing left to surface or repair it.
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
enqueue({ data: { journal_entry_id: null } })
@@ -177,6 +183,102 @@ describe('propagateUnderlagForBookedTransaction', () => {
).resolves.toBeUndefined()
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
// ── The transaction's own pinned document (transactions.document_id) ──
// A doc attached directly to the transaction has no inbox item to carry
// it, so the propagation must anchor it itself: this was the 2026-08-13
// "Underlag saknas" gap (attach-before-book via the manual booking dialog).
it('anchors the pinned document even when no inbox item exists', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } }) // tx pin lookup
enqueue({ data: { journal_entry_id: null } }) // pinned doc not yet anchored
enqueue({ data: [] }) // no matched inbox items
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-pin', JE1)
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
})
it('leaves a pinned document that already points at this verifikat alone', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } })
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored here
enqueue({ data: [] })
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('never steals a pinned document anchored to another verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } })
enqueue({ data: { journal_entry_id: 'je-other' } })
enqueue({ data: [] })
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('still processes inbox items when the pinned-document link fails', async () => {
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-pin' } })
enqueue({ data: { journal_entry_id: null } }) // pinned doc; link will throw
enqueue({ data: [{ id: 'i1', document_id: null }] }) // docless matched item
enqueue({ data: null }) // stamp update
await expect(
propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
),
).resolves.toBeUndefined()
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
it('links a document shared by the pin and a matched item only once', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: 'doc-1' } }) // pin points at the same doc
enqueue({ data: { journal_entry_id: null } }) // pin leg links it
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] }) // matched item, same doc
enqueue({ data: { journal_entry_id: JE1 } }) // item leg finds it anchored
enqueue({ data: null }) // stamp update
await propagateUnderlagForBookedTransaction(
supabase as unknown as SupabaseClient,
COMPANY,
TX1,
JE1,
)
expect(linkToJournalEntry).toHaveBeenCalledTimes(1)
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
{ created_journal_entry_id: JE1 },
])
})
})
describe('completeInboxItemsForBookedTransaction', () => {
@@ -196,8 +298,9 @@ describe('completeInboxItemsForBookedTransaction', () => {
expect(findCalls('invoice_inbox_items', 'select').length).toBe(0)
})
it('skips the transaction fetch when the caller passes the direct id', async () => {
it('skips the resolution fetch when the caller passes the direct id', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { document_id: null } }) // tx pin lookup inside propagate
enqueue({ data: [] }) // matched items (none)
const result = await completeInboxItemsForBookedTransaction(
@@ -207,12 +310,15 @@ describe('completeInboxItemsForBookedTransaction', () => {
{ directJournalEntryId: JE1 },
)
expect(result).toBe(JE1)
expect(findCalls('transactions', 'select').length).toBe(0)
// The only transactions read is the pin lookup inside the propagation:
// the id/journal_entry_id resolution query never runs.
expect(findCalls('transactions', 'select')).toEqual([['document_id']])
})
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: { document_id: null } }) // tx pin lookup inside propagate
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched items
enqueue({ data: null }) // stamp update
+93 -43
View File
@@ -16,9 +16,14 @@
* 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
* - 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) and stamp created_journal_entry_id.
* 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.
*
@@ -116,18 +121,69 @@ export async function resolveVoucherLinkedEntryIds(
}
/**
* 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
* 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
* 2. for each item with a document_id, set
* 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
* 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox row
* 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.
@@ -139,6 +195,24 @@ export async function propagateUnderlagForBookedTransaction(
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')
@@ -156,42 +230,18 @@ export async function propagateUnderlagForBookedTransaction(
// (.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.
// 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) {
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,
})
}
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