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
@@ -57,7 +57,12 @@ describe('getJournalEntryUnderlagReferences', () => {
{ data: [] }, // 1. invoices direct
{ data: [] }, // 2. invoice_payments
{ data: [] }, // 4. supplier registration
{ data: [{ id: 'si-1', supplier_invoice_number: 'LF-001', document_id: 'doc-1' }] }, // 5. supplier payment
{ data: [{
id: 'si-1',
supplier_invoice_number: 'LF-001',
document_id: 'doc-1',
document: { journal_entry_id: 'je-9' },
}] }, // 5. supplier payment
{ data: [{ supplier_invoice_id: 'si-1' }] }, // 6. supplier_invoice_payments
])
@@ -69,6 +74,49 @@ describe('getJournalEntryUnderlagReferences', () => {
}])
})
it('withholds a FLOATING supplier-invoice document but keeps the reference', async () => {
// An unanchored document (journal_entry_id IS NULL) sits outside the WORM
// deletion guards, so every missing-underlag surface refuses to count it.
// Handing it out here is what made the verifikat view show an underlag
// while the list warned "Underlag saknas" on the same row.
const refs = await run([
{ data: [] }, // 1. invoices direct
{ data: [] }, // 2. invoice_payments
{ data: [] }, // 4. supplier registration
{ data: [{
id: 'si-1',
supplier_invoice_number: 'LF-001',
document_id: 'doc-1',
document: { journal_entry_id: null },
}] }, // 5. supplier payment
{ data: [{ supplier_invoice_id: 'si-1' }] }, // 6. supplier_invoice_payments
])
expect(refs).toEqual([{ type: 'supplier_invoice', id: 'si-1', number: 'LF-001' }])
})
it('accepts the embedded document row in PostgREST array form', async () => {
const refs = await run([
{ data: [] }, // 1. invoices direct
{ data: [] }, // 2. invoice_payments
{ data: [{
id: 'si-1',
supplier_invoice_number: 'LF-001',
document_id: 'doc-1',
document: [{ journal_entry_id: 'je-9' }],
}] }, // 4. supplier registration
{ data: [] }, // 5. supplier payment
{ data: [] }, // 6. supplier_invoice_payments
])
expect(refs).toEqual([{
type: 'supplier_invoice',
id: 'si-1',
number: 'LF-001',
document_id: 'doc-1',
}])
})
it('returns nothing when no invoice is linked (warning legitimately stays)', async () => {
const refs = await run([
{ data: [] }, // 1. invoices direct
@@ -21,7 +21,20 @@ export interface UnderlagReference {
id: string
/** invoice_number / supplier_invoice_number: the UI builds the label from this. */
number: string
/** Retained source document owned by a referenced supplier invoice, if any. */
/**
* Retained source document owned by a referenced supplier invoice, if any.
*
* Set ONLY when the document is anchored to a journal entry
* (document_attachments.journal_entry_id IS NOT NULL), because that is the
* exact condition every missing-underlag surface uses: the
* verifikat_without_documents / transactions_without_documents RPCs,
* /api/documents/counts and the transactions list all require an anchored
* doc, since only anchored docs sit behind the WORM deletion guards. Handing
* out a floating doc here made the verifikat view display an underlag while
* the list kept warning "Underlag saknas" on the same row (support case
* 2026-07-27). The reference itself is still returned either way, so the
* verifieringskedja stays followable; only the attachment claim is withheld.
*/
document_id?: string
}
@@ -34,6 +47,24 @@ interface SupplierInvoiceRow {
id: string
supplier_invoice_number: string
document_id?: string | null
/** Embedded document row; see UnderlagReference.document_id for why. */
document?: { journal_entry_id: string | null } | { journal_entry_id: string | null }[] | null
}
/** Columns every supplier-invoice lookup below needs, incl. the anchor check. */
const SUPPLIER_INVOICE_COLUMNS =
'id, supplier_invoice_number, document_id, document:document_attachments(journal_entry_id)'
/**
* A supplier invoice's document only counts as this verifikation's underlag
* when it is anchored to a journal entry: an unanchored doc is outside the WORM
* deletion guards, so the missing-underlag surfaces refuse to accept it and
* this resolver must refuse too.
*/
function anchoredDocumentId(row: SupplierInvoiceRow): string | undefined {
if (!row.document_id) return undefined
const document = Array.isArray(row.document) ? row.document[0] : row.document
return document?.journal_entry_id ? row.document_id : undefined
}
/**
@@ -94,29 +125,31 @@ export async function getJournalEntryUnderlagReferences(
// Registration booking (accrual) on the invoice itself.
const registrationLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
supabase.from('supplier_invoices').select(SUPPLIER_INVOICE_COLUMNS)
.eq('company_id', companyId).eq('registration_journal_entry_id', journalEntryId)
.order('id', { ascending: true }).range(from, to),
)
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
const documentId = anchoredDocumentId(si)
supplierInvoices.set(si.id, {
number: si.supplier_invoice_number,
...(si.document_id ? { documentId: si.document_id } : {}),
...(documentId ? { documentId } : {}),
})
}
// Payment booking on the invoice itself.
const paymentLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
supabase.from('supplier_invoices').select(SUPPLIER_INVOICE_COLUMNS)
.eq('company_id', companyId).eq('payment_journal_entry_id', journalEntryId)
.order('id', { ascending: true }).range(from, to),
)
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
const documentId = anchoredDocumentId(si)
supplierInvoices.set(si.id, {
number: si.supplier_invoice_number,
...(si.document_id ? { documentId: si.document_id } : {}),
...(documentId ? { documentId } : {}),
})
}
@@ -135,15 +168,16 @@ export async function getJournalEntryUnderlagReferences(
if (supplierPaymentIds.size > 0) {
const paidSupplierInvoices = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
supabase.from('supplier_invoices').select('id, supplier_invoice_number, document_id')
supabase.from('supplier_invoices').select(SUPPLIER_INVOICE_COLUMNS)
.eq('company_id', companyId).in('id', Array.from(supplierPaymentIds))
.order('id', { ascending: true }).range(from, to),
)
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
const documentId = anchoredDocumentId(si)
supplierInvoices.set(si.id, {
number: si.supplier_invoice_number,
...(si.document_id ? { documentId: si.document_id } : {}),
...(documentId ? { documentId } : {}),
})
}
}