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 } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user