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:
co-authored by
Claude Fable 5
parent
314efe8b22
commit
d9dddba682
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user