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,312 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
anchorSupplierInvoiceDocument,
reanchorOrphanedSupplierInvoiceDocuments,
} from '../supplier-invoice-underlag'
/**
* anchorSupplierInvoiceDocument issues its queries in a fixed `.from()` order,
* and the queued mock consumes one enqueued result per `.from()` call:
* 1. supplier_invoices (the invoice + its FK verifikat)
* 2. document_attachments (the retained document, anchor check)
* 3. supplier_invoice_payments (partial-payment verifikat candidates)
* 4. journal_entries (status + period of every candidate)
* 5. document_attachments (the anchoring UPDATE)
* Steps 3-5 are skipped when the document needs no anchoring.
*/
describe('anchorSupplierInvoiceDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const openPeriod = { is_closed: false, locked_at: null }
it('anchors a floating document to the payment verifikat when registration was reversed', async () => {
// The reported case: the invoice PDF was orphaned when the rättelse it had
// been relinked onto was deleted, leaving the posted payment verifikat
// warning "Underlag saknas" while the verifikat view showed the PDF.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: 'je-pay',
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{
data: [
{ id: 'je-reg', status: 'reversed', fiscal_period: openPeriod },
{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod },
],
},
{ data: null },
])
const anchored = await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
)
expect(anchored).toBe('je-pay')
expect(supabase.from).toHaveBeenCalledTimes(5)
})
it('prefers the registration verifikat: it is the primary booking', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: 'je-pay',
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{
data: [
{ id: 'je-reg', status: 'posted', fiscal_period: openPeriod },
{ id: 'je-pay', status: 'posted', fiscal_period: openPeriod },
],
},
{ data: null },
])
expect(
await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
),
).toBe('je-reg')
})
it('falls back to a partial-payment verifikat, oldest first', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: null,
payment_journal_entry_id: null,
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [{ journal_entry_id: 'je-p1' }, { journal_entry_id: 'je-p2' }] },
{
data: [
{ id: 'je-p1', status: 'posted', fiscal_period: openPeriod },
{ id: 'je-p2', status: 'posted', fiscal_period: openPeriod },
],
},
{ data: null },
])
expect(
await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
),
).toBe('je-p1')
})
it('never moves a document that is already anchored', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: 'je-pay',
},
},
{ data: { id: 'doc-1', journal_entry_id: 'je-reg', is_current_version: true } },
])
expect(
await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
),
).toBeNull()
// Stopped before the candidate lookup: no UPDATE was attempted.
expect(supabase.from).toHaveBeenCalledTimes(2)
})
it('leaves a superseded document alone', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: null,
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: false } },
])
expect(
await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
),
).toBeNull()
expect(supabase.from).toHaveBeenCalledTimes(2)
})
it('skips a candidate whose period is locked (the trigger would reject the write)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: null,
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{
data: [
{
id: 'je-reg',
status: 'posted',
fiscal_period: { is_closed: false, locked_at: '2026-07-01T00:00:00Z' },
},
],
},
])
expect(
await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
),
).toBeNull()
// Candidates were resolved, but no UPDATE followed.
expect(supabase.from).toHaveBeenCalledTimes(4)
})
it('does nothing when the invoice has no retained document', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: null,
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: null,
},
},
])
expect(
await anchorSupplierInvoiceDocument(
supabase as unknown as SupabaseClient,
'company-1',
'si-1',
),
).toBeNull()
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('reports failure as null instead of throwing at the caller', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: null,
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{ data: [{ id: 'je-reg', status: 'posted', fiscal_period: openPeriod }] },
{ error: { message: 'period locked' } },
])
await expect(
anchorSupplierInvoiceDocument(supabase as unknown as SupabaseClient, 'company-1', 'si-1'),
).resolves.toBeNull()
})
})
describe('reanchorOrphanedSupplierInvoiceDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('re-anchors the supplier invoice whose document a deleted voucher orphaned', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [{ id: 'si-1' }] }, // supplier_invoices by document_id
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: null,
payment_journal_entry_id: 'je-pay',
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{
data: [{ id: 'je-pay', status: 'posted', fiscal_period: { is_closed: false, locked_at: null } }],
},
{ data: null },
])
expect(
await reanchorOrphanedSupplierInvoiceDocuments(
supabase as unknown as SupabaseClient,
'company-1',
['doc-1'],
),
).toBe(1)
})
it('leaves a plain receipt floating so it returns to the unlinked pool', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: [] }]) // no supplier invoice owns this document
expect(
await reanchorOrphanedSupplierInvoiceDocuments(
supabase as unknown as SupabaseClient,
'company-1',
['doc-9'],
),
).toBe(0)
})
it('short-circuits when the deleted voucher had no documents', async () => {
const { supabase } = createQueuedMockSupabase()
expect(
await reanchorOrphanedSupplierInvoiceDocuments(
supabase as unknown as SupabaseClient,
'company-1',
[],
),
).toBe(0)
expect(supabase.from).not.toHaveBeenCalled()
})
})