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
@@ -28,6 +28,7 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -505,6 +506,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Anchor the invoice's retained source document to a posted verifikat if
// it is still floating. Under kontantmetoden the cash entry we just booked
// is the only booking of the affärshändelse, so its underlag belongs here
// (BFL 5 kap 6 §); under faktureringsmetoden the document is normally
// already on the registration verifikat and this is a no-op. Without it an
// API-key/MCP-driven payment leaves the document unanchored, which every
// missing-underlag surface reads as "Underlag saknas". Never throws.
await anchorSupplierInvoiceDocument(ctx.supabase, ctx.companyId!, invoiceId)
try {
await eventBus.emit({
type: 'supplier_invoice.paid',
@@ -20,6 +20,7 @@ import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
@@ -503,6 +504,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
}
// Same requirement for the SUPPLIER INVOICE's own retained document: it
// must sit on a posted verifikat or the missing-underlag surfaces (which
// only accept an anchored doc) warn on a verifikat that plainly shows the
// invoice. No-op when it is already anchored. Never throws.
await anchorSupplierInvoiceDocument(ctx.supabase, ctx.companyId!, supplier_invoice_id)
logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
supplierInvoiceId: supplier_invoice_id,
matchConfidence: 1.0,