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 } : {}),
})
}
}
@@ -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()
})
})
@@ -0,0 +1,221 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('supplier-invoice-underlag')
/**
* Re-anchor a supplier invoice's retained source document to one of the
* invoice's own posted verifikat when the document is currently floating
* (document_attachments.journal_entry_id IS NULL).
*
* Why this exists (support case 2026-07-27): every missing-underlag surface
* (verifikat_without_documents / transactions_without_documents RPCs,
* /api/documents/counts, the transactions list) only accepts a referenced
* supplier-invoice document as underlag when it is ANCHORED to a journal
* entry, because only anchored docs sit behind the WORM deletion guards
* (block_document_deletion keys on journal_entry_id). A floating document
* therefore keeps "Underlag saknas" alive on a verifikat that plainly shows
* the invoice PDF, and the user has no way to resolve it: the nag is supposed
* to get the document anchored, but nothing anchored it.
*
* Documents end up floating two ways, both seen in production:
* 1. delete_last_voucher clears journal_entry_id on every document hanging
* on the deleted voucher (it has to: the FK is ON DELETE RESTRICT). When
* that voucher was a rättelse the invoice's PDF had been relinked onto,
* the invoice is left holding an unanchored document while its payment
* verifikat is still posted.
* 2. Payment/cash verifikat booked for an invoice whose document was never
* anchored at registration (attached after the fact, or booked through a
* path that did not link it).
*
* Anchoring is strictly an improvement: it puts the document behind the
* deletion guard and makes the hänvisning (BFL 5 kap 7 §) legally solid, and
* the immutability triggers explicitly allow NULL -> uuid
* (enforce_document_journal_entry_immutability returns early when
* OLD.journal_entry_id IS NULL). An already-anchored document is never moved.
*
* Returns the journal entry id the document was anchored to, or null when
* nothing needed doing (no document, already anchored, no eligible verifikat).
* Never throws: every caller runs after a committed, immutable booking, so a
* failure here must be logged, not surfaced.
*/
export async function anchorSupplierInvoiceDocument(
supabase: SupabaseClient,
companyId: string,
supplierInvoiceId: string,
): Promise<string | null> {
try {
const { data: invoice } = await supabase
.from('supplier_invoices')
.select('id, document_id, registration_journal_entry_id, payment_journal_entry_id')
.eq('id', supplierInvoiceId)
.eq('company_id', companyId)
.maybeSingle()
const documentId = (invoice as { document_id?: string | null } | null)?.document_id
if (!documentId) return null
const { data: document } = await supabase
.from('document_attachments')
.select('id, journal_entry_id, is_current_version')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
const doc = document as
| { id: string; journal_entry_id: string | null; is_current_version: boolean }
| null
// Already anchored (the normal case), superseded, or gone: leave it alone.
// Moving an anchored doc is blocked by the immutability trigger anyway.
if (!doc || doc.journal_entry_id || doc.is_current_version !== true) return null
const entryId = await pickAnchorEntry(
supabase,
companyId,
supplierInvoiceId,
invoice as {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
},
)
if (!entryId) return null
const { error } = await supabase
.from('document_attachments')
.update({ journal_entry_id: entryId })
.eq('id', doc.id)
.eq('company_id', companyId)
// Concurrency guard: a parallel booking may have anchored it since the
// read above. Never steal a document that already serves a verifikat.
.is('journal_entry_id', null)
.eq('is_current_version', true)
if (error) {
log.warn('failed to anchor supplier invoice document to verifikat', {
companyId,
supplierInvoiceId,
documentId: doc.id,
journalEntryId: entryId,
reason: error.message,
})
return null
}
return entryId
} catch (err) {
log.warn('anchorSupplierInvoiceDocument threw', {
companyId,
supplierInvoiceId,
reason: err instanceof Error ? err.message : String(err),
})
return null
}
}
/**
* The invoice's own verifikat, in the order BFL wants the underlag to hang:
* the registration booking is the primary booking of the affärshändelse, the
* payment booking is the fallback (and the only booking under kontantmetoden),
* then any partial-payment verifikat, oldest first.
*
* Only posted entries in open, unlocked periods qualify: a reversed entry is
* no longer a live booking, and enforce_period_lock_documents rejects the
* write outright once the period is closed or locked.
*/
async function pickAnchorEntry(
supabase: SupabaseClient,
companyId: string,
supplierInvoiceId: string,
invoice: {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
},
): Promise<string | null> {
const candidates: string[] = []
const push = (id: string | null | undefined) => {
if (id && !candidates.includes(id)) candidates.push(id)
}
push(invoice.registration_journal_entry_id)
push(invoice.payment_journal_entry_id)
const { data: paymentRows } = await supabase
.from('supplier_invoice_payments')
.select('journal_entry_id, payment_date')
.eq('company_id', companyId)
.eq('supplier_invoice_id', supplierInvoiceId)
.not('journal_entry_id', 'is', null)
.order('payment_date', { ascending: true })
for (const row of (paymentRows ?? []) as { journal_entry_id: string | null }[]) {
push(row.journal_entry_id)
}
if (candidates.length === 0) return null
const { data: entries } = await supabase
.from('journal_entries')
.select('id, status, fiscal_period:fiscal_periods(is_closed, locked_at)')
.eq('company_id', companyId)
.in('id', candidates)
type EntryRow = {
id: string
status: string
fiscal_period:
| { is_closed: boolean | null; locked_at: string | null }
| { is_closed: boolean | null; locked_at: string | null }[]
| null
}
const byId = new Map<string, EntryRow>(
((entries ?? []) as unknown as EntryRow[]).map((entry) => [entry.id, entry]),
)
for (const id of candidates) {
const entry = byId.get(id)
if (!entry || entry.status !== 'posted') continue
// PostgREST returns an embedded to-one either as an object or, depending
// on how it resolves the relationship, as a single-element array.
const period = Array.isArray(entry.fiscal_period) ? entry.fiscal_period[0] : entry.fiscal_period
if (period?.is_closed || period?.locked_at) continue
return id
}
return null
}
/**
* Re-anchor the supplier-invoice documents that a just-deleted voucher left
* floating. Takes the document ids that hung on the voucher before it was torn
* down (delete_last_voucher nulls their journal_entry_id), and re-points those
* that are a supplier invoice's retained source document at another posted
* verifikat of the same invoice.
*
* Documents that belong to no supplier invoice are left floating on purpose:
* a receipt uploaded straight to the deleted voucher SHOULD return to the
* unlinked pool so the user can attach it to the replacement booking.
*
* Returns the number of documents re-anchored.
*/
export async function reanchorOrphanedSupplierInvoiceDocuments(
supabase: SupabaseClient,
companyId: string,
documentIds: string[],
): Promise<number> {
if (documentIds.length === 0) return 0
try {
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('id')
.eq('company_id', companyId)
.in('document_id', documentIds)
let anchored = 0
for (const invoice of ((invoices ?? []) as { id: string }[])) {
if (await anchorSupplierInvoiceDocument(supabase, companyId, invoice.id)) anchored++
}
return anchored
} catch (err) {
log.warn('reanchorOrphanedSupplierInvoiceDocuments threw', {
companyId,
reason: err instanceof Error ? err.message : String(err),
})
return 0
}
}