69c537fd1f
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>
222 lines
8.3 KiB
TypeScript
222 lines
8.3 KiB
TypeScript
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
|
|
}
|
|
}
|