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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
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',
|
|
[],
|
|
)
|
|
})
|
|
})
|