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
@@ -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 () => {