fix(documents): anchor floating supplier-invoice underlag instead of nagging (#1248)

A verifikat booked from a supplier invoice showed the invoice PDF when opened
while the list kept warning "Underlag saknas" on the same row. Both surfaces
behaved as written: every missing-underlag surface only accepts a referenced
supplier-invoice document when it is ANCHORED to a journal entry (only anchored
docs sit behind block_document_deletion), while the verifikat view's reference
resolver displayed the document regardless.

The document was floating because delete_last_voucher clears journal_entry_id
on everything attached to the voucher it tears down (the FK is ON DELETE
RESTRICT, so it must). Deleting a rättelse the invoice PDF had been relinked
onto therefore orphaned it while the payment verifikat stayed posted, and
nothing ever anchored it again: the warning was unresolvable by design.

Same class one surface over: v1 mark-paid never linked the document at all,
dashboard mark-paid only did so for the cash entry, and both
match-supplier-invoice routes propagated the transaction's document but not the
invoice's own. Four of the five affected prod rows come from those paths, not
from a deleted voucher.

- lib/core/documents/supplier-invoice-underlag.ts: anchor a floating document
  to the invoice's own posted verifikat (registration, then payment, then
  partial payments; open unlocked periods only). Never moves an anchored doc,
  never throws.
- Called after delete_last_voucher and from all four payment paths.
- getJournalEntryUnderlagReferences withholds an unanchored document so the
  verifikat view and the warning can no longer contradict each other.
- Migration 20260727180000 backfills the rows already in this state.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-27 18:04:15 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 771dfd45ba
commit 69c537fd1f
14 changed files with 1116 additions and 45 deletions
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock('@/lib/bookkeeping/payment-sync', () => ({
syncInvoiceStatusFromPaymentEntry: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({
reanchorOrphanedSupplierInvoiceDocuments: vi.fn().mockResolvedValue(0),
}))
import { reanchorOrphanedSupplierInvoiceDocuments } from '@/lib/core/documents/supplier-invoice-underlag'
import { DELETE } from '../route'
/**
* The DELETE handler's `.from()` / `.rpc()` order, one queued result each:
* 1. journal_entries (source_type/source_id, read before the teardown)
* 2. document_attachments (documents about to be orphaned by the RPC)
* 3. rpc delete_last_voucher
*/
describe('DELETE /api/bookkeeping/journal-entries/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
const run = () =>
DELETE(
createMockRequest('/api/bookkeeping/journal-entries/je-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'je-1' }),
)
it('re-anchors the documents the deleted voucher orphaned', async () => {
// delete_last_voucher has to clear journal_entry_id on every attached
// document (the FK is ON DELETE RESTRICT). A supplier invoice's retained
// PDF must not be left floating: unanchored, it stops counting as underlag
// everywhere while still showing up on the invoice's other verifikat.
enqueue({ data: { id: 'je-1', source_type: 'correction', source_id: null } })
enqueue({ data: [{ id: 'doc-1' }, { id: 'doc-2' }] })
enqueue({ data: { deleted: true, voucher_series: 'A', voucher_number: 12 } })
const { status } = await parseJsonResponse(await run())
expect(status).toBe(200)
expect(reanchorOrphanedSupplierInvoiceDocuments).toHaveBeenCalledWith(
expect.anything(),
'company-1',
['doc-1', 'doc-2'],
)
})
it('does not re-anchor when the RPC refused the delete', async () => {
enqueue({ data: { id: 'je-1', source_type: 'manual', source_id: null } })
enqueue({ data: [{ id: 'doc-1' }] })
enqueue({ error: { message: 'Kan bara radera det sista verifikatet i serien.' } })
const { status } = await parseJsonResponse(await run())
expect(status).toBe(400)
expect(reanchorOrphanedSupplierInvoiceDocuments).not.toHaveBeenCalled()
})
it('passes an empty list when the voucher had no documents', async () => {
enqueue({ data: { id: 'je-1', source_type: 'manual', source_id: null } })
enqueue({ data: [] })
enqueue({ data: { deleted: true, voucher_series: 'A', voucher_number: 3 } })
const { status } = await parseJsonResponse(await run())
expect(status).toBe(200)
expect(reanchorOrphanedSupplierInvoiceDocuments).toHaveBeenCalledWith(
expect.anything(),
'company-1',
[],
)
})
})
@@ -9,6 +9,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { updateDraftEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { reanchorOrphanedSupplierInvoiceDocuments } from '@/lib/core/documents/supplier-invoice-underlag'
const logger = createLogger('journal-entries')
@@ -56,6 +57,19 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('company_id', companyId)
.single()
// delete_last_voucher clears journal_entry_id on every document hanging on
// the voucher (the FK is ON DELETE RESTRICT, so it has no choice). Capture
// them first: a document that is a supplier invoice's retained source
// document must be re-anchored to another posted verifikat of that invoice
// afterwards, or the invoice's remaining verifikat is left showing the PDF
// while every missing-underlag surface (which requires an anchored doc)
// keeps warning "Underlag saknas" with no way for the user to resolve it.
const { data: linkedDocs } = await supabase
.from('document_attachments')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', id)
const { data, error } = await supabase.rpc('delete_last_voucher', {
p_company_id: companyId,
p_entry_id: id,
@@ -77,6 +91,19 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
const orphanedDocIds = ((linkedDocs ?? []) as { id: string }[]).map((doc) => doc.id)
const reanchored = await reanchorOrphanedSupplierInvoiceDocuments(
supabase,
companyId,
orphanedDocIds,
)
if (reanchored > 0) {
logger.info('re-anchored supplier invoice documents after voucher delete', {
entryId: id,
count: reanchored,
})
}
await eventBus.emit({
type: 'journal_entry.deleted',
payload: {