diff --git a/DECISIONS.md b/DECISIONS.md index da72b33c..ca3d87d4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -896,3 +896,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index d9852d93..8de3896a 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -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 () => { diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 8538a4a3..23ac896e 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -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 diff --git a/lib/transactions/__tests__/categorize-core.bulk.test.ts b/lib/transactions/__tests__/categorize-core.bulk.test.ts index fdb88a50..04c839ca 100644 --- a/lib/transactions/__tests__/categorize-core.bulk.test.ts +++ b/lib/transactions/__tests__/categorize-core.bulk.test.ts @@ -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 () => { diff --git a/lib/transactions/__tests__/inbox-underlag.test.ts b/lib/transactions/__tests__/inbox-underlag.test.ts index ec178650..16bcac2f 100644 --- a/lib/transactions/__tests__/inbox-underlag.test.ts +++ b/lib/transactions/__tests__/inbox-underlag.test.ts @@ -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 diff --git a/lib/transactions/inbox-underlag.ts b/lib/transactions/inbox-underlag.ts index ddfcf4be..33e8f536 100644 --- a/lib/transactions/inbox-underlag.ts +++ b/lib/transactions/inbox-underlag.ts @@ -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, +): Promise { + 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 { 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