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:
co-authored by
Claude Opus 5
parent
771dfd45ba
commit
69c537fd1f
@@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user