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
@@ -467,3 +467,210 @@ describe('transaction-pinned document backfill (migration 20260724090000 §4)',
expect(cRows[0].journal_entry_id).toBeNull()
})
})
describe('floating supplier-invoice document backfill (migration 20260727180000)', () => {
// The DO-block body, verbatim from the migration: re-anchor a supplier
// invoice's retained document when it is floating (journal_entry_id NULL)
// even though the invoice still has a posted verifikat to hang on. Preference
// order: registration booking, payment booking, then partial payments.
const BACKFILL_SQL = `
WITH candidate AS (
SELECT
si.document_id,
si.company_id,
je.id AS journal_entry_id,
ROW_NUMBER() OVER (
PARTITION BY si.document_id
ORDER BY rank_source, coalesce(sip.payment_date, je.entry_date), je.id
) AS pick
FROM supplier_invoices si
JOIN document_attachments d
ON d.id = si.document_id
AND d.company_id = si.company_id
AND d.journal_entry_id IS NULL
AND d.is_current_version = true
CROSS JOIN LATERAL (
SELECT si.registration_journal_entry_id AS entry_id, 1 AS rank_source, NULL::uuid AS payment_id
UNION ALL
SELECT si.payment_journal_entry_id, 2, NULL::uuid
UNION ALL
SELECT p.journal_entry_id, 3, p.id
FROM supplier_invoice_payments p
WHERE p.supplier_invoice_id = si.id
AND p.company_id = si.company_id
AND p.journal_entry_id IS NOT NULL
) AS src(entry_id, rank_source, payment_id)
LEFT JOIN supplier_invoice_payments sip ON sip.id = src.payment_id
JOIN journal_entries je
ON je.id = src.entry_id
AND je.company_id = si.company_id
AND je.status = 'posted'
JOIN fiscal_periods fp
ON fp.id = je.fiscal_period_id
AND fp.is_closed = false
AND fp.locked_at IS NULL
)
UPDATE document_attachments d
SET journal_entry_id = candidate.journal_entry_id
FROM candidate
WHERE candidate.pick = 1
AND d.id = candidate.document_id
AND d.company_id = candidate.company_id
AND d.journal_entry_id IS NULL
AND d.is_current_version = true`
const anchorOf = async (documentId: string): Promise<string | null> => {
const { rows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[documentId],
)
return rows[0].journal_entry_id
}
it('anchors a floating doc to the payment verifikat when registration was reversed', async () => {
// The reported shape: the invoice PDF was orphaned when the rättelse it
// had been relinked onto was deleted (delete_last_voucher has to clear
// journal_entry_id), leaving the posted payment verifikat flagged while
// the verifikat view still displayed the PDF.
const s = await seedCompany()
const mkJe = async (n: number, status: 'posted' | 'reversed', sourceType: string) => {
const id = await insertDraftJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
status,
voucherNumber: n,
entryDate: '2026-06-15',
description: `anchor ${n}`,
sourceType,
})
await insertBalancedLines(id, 100 * n)
return id
}
const jeReg = await mkJe(1, 'reversed', 'supplier_invoice_registered')
const jePay = await mkJe(2, 'posted', 'supplier_invoice_paid')
const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId })
const doc = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 1,
registrationJournalEntryId: jeReg,
paymentJournalEntryId: jePay,
documentId: doc,
})
// Before: the payment verifikat is flagged even though the PDF is retained.
const before = await verifikatSurface(s.companyId)
expect((before.verifikat ?? []).map((v) => v.journal_entry_id)).toContain(jePay)
await getPool().query(BACKFILL_SQL)
expect(await anchorOf(doc)).toBe(jePay)
const after = await verifikatSurface(s.companyId)
expect((after.verifikat ?? []).map((v) => v.journal_entry_id)).not.toContain(jePay)
})
it('prefers the registration verifikat and never steals an anchored doc', async () => {
const s = await seedCompany()
const mkJe = async (n: number, sourceType: string) => {
const id = await insertDraftJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
status: 'posted',
voucherNumber: n,
entryDate: '2026-06-15',
description: `prefer ${n}`,
sourceType,
})
await insertBalancedLines(id, 100 * n)
return id
}
const jeReg = await mkJe(1, 'supplier_invoice_registered')
const jePay = await mkJe(2, 'supplier_invoice_paid')
const jeOther = await mkJe(3, 'manual')
const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId })
const floating = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 1,
registrationJournalEntryId: jeReg,
paymentJournalEntryId: jePay,
documentId: floating,
})
// Already serving another verifikat: must stay put (BFL 5 kap 6 §).
const anchored = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeOther,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 2,
paymentJournalEntryId: jePay,
documentId: anchored,
})
await getPool().query(BACKFILL_SQL)
expect(await anchorOf(floating)).toBe(jeReg)
expect(await anchorOf(anchored)).toBe(jeOther)
})
it('skips closed periods: the period-lock trigger would reject the write anyway', async () => {
const s = await seedCompany()
const je = await insertDraftJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
status: 'posted',
voucherNumber: 1,
entryDate: '2026-06-15',
description: 'closed period',
sourceType: 'supplier_invoice_paid',
})
await insertBalancedLines(je, 100)
const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId })
const doc = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 1,
paymentJournalEntryId: je,
documentId: doc,
})
// Close AFTER the fixtures exist: inserting into a closed period is itself
// blocked by enforce_period_lock.
await getPool().query(
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
[s.fiscalPeriodId],
)
await getPool().query(BACKFILL_SQL)
expect(await anchorOf(doc)).toBeNull()
})
})