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:
@@ -620,3 +620,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-27] performSync keeps `provider` optional, defaulting to Google Drive: it preserves the pre-Dropbox call shape (and its test suite) as the documented legacy path while all three production call sites pass a provider explicitly.
|
||||
[2026-07-27] Privacy page corrected after verifying prod: PostHog's conversations module writes ph_conv_<token> = {"widgetSessionId": uuid} to localStorage at PAGE LOAD, not when support is contacted, bypassing persistence: 'memory' exactly like surveys' seenSurvey_ flags. The page previously claimed enkäter were the only local storage, which was wrong the moment Support was enabled. Both values are functional UI state without personuppgifter (do-not-ask-again, and find-my-own-ticket), so the no-consent-banner position holds, but "inga kakor eller annan lagring" did not and is now stated accurately. Worth remembering that enabling any new PostHog product can silently add device storage: check localStorage in prod after each one rather than trusting the persistence setting.
|
||||
[2026-07-27] Documented the two client-side storage keys as having DIFFERENT lifecycles, after the compliance swarm flagged ISO 27001 A.8.10 (no deletion mechanism) on the storage inventory. ph_conv_<token> IS deleted on logout: posthog.reset() calls the conversations manager's own reset, which removes that single known key, and resetAnalyticsIdentity() already runs in both logout handlers. That is what stops a shared device carrying one user's support-ticket session into the next user's session. seenSurvey_<id> genuinely has no deletion trigger, and cannot have one from the SDK: no PostHog bundle enumerates localStorage (zero occurrences of localStorage.key( or Object.keys(localStorage) across module.js, surveys.js, conversations.js), so nothing can discover the keys to remove them. Accepted as the retention position rather than an oversight: the value is "true" under an opaque survey id with no personal data, and clearing it on logout would re-prompt every survey to the next person on the device and produce false survey responses.
|
||||
[2026-07-27] Fixed the "Underlag saknas on a verifikat that shows the invoice" contradiction by ANCHORING the floating document, not by loosening the missing-underlag predicate. The predicate's anchoring requirement (migration 20260724090000) is legally right: block_document_deletion keys on journal_entry_id, so an unanchored doc is deletable and must not silence the warning. The bug was that nothing ever anchored it, and delete_last_voucher actively un-anchors (it must: the FK is ON DELETE RESTRICT). So the fix re-anchors after a voucher delete, at every supplier-invoice payment path (dashboard + v1 mark-paid, dashboard + v1 match-supplier-invoice, which between them had zero, partial and cash-only coverage), and backfills the 5 prod rows. getJournalEntryUnderlagReferences now also withholds an unanchored document, so the verifikat view and the list can no longer state opposite things about the same row. Rejected the alternative (extend the WORM deletion guard to protect docs referenced by a supplier invoice, then drop the anchoring requirement everywhere): it touches an enforcement trigger for a strictly larger blast radius and leaves the document outside the guard until the trigger ships.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/payment-sync', () => ({
|
||||
syncInvoiceStatusFromPaymentEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({
|
||||
reanchorOrphanedSupplierInvoiceDocuments: vi.fn().mockResolvedValue(0),
|
||||
}))
|
||||
|
||||
import { reanchorOrphanedSupplierInvoiceDocuments } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
|
||||
import { DELETE } from '../route'
|
||||
|
||||
/**
|
||||
* The DELETE handler's `.from()` / `.rpc()` order, one queued result each:
|
||||
* 1. journal_entries (source_type/source_id, read before the teardown)
|
||||
* 2. document_attachments (documents about to be orphaned by the RPC)
|
||||
* 3. rpc delete_last_voucher
|
||||
*/
|
||||
describe('DELETE /api/bookkeeping/journal-entries/[id]', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
const run = () =>
|
||||
DELETE(
|
||||
createMockRequest('/api/bookkeeping/journal-entries/je-1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'je-1' }),
|
||||
)
|
||||
|
||||
it('re-anchors the documents the deleted voucher orphaned', async () => {
|
||||
// delete_last_voucher has to clear journal_entry_id on every attached
|
||||
// document (the FK is ON DELETE RESTRICT). A supplier invoice's retained
|
||||
// PDF must not be left floating: unanchored, it stops counting as underlag
|
||||
// everywhere while still showing up on the invoice's other verifikat.
|
||||
enqueue({ data: { id: 'je-1', source_type: 'correction', source_id: null } })
|
||||
enqueue({ data: [{ id: 'doc-1' }, { id: 'doc-2' }] })
|
||||
enqueue({ data: { deleted: true, voucher_series: 'A', voucher_number: 12 } })
|
||||
|
||||
const { status } = await parseJsonResponse(await run())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(reanchorOrphanedSupplierInvoiceDocuments).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
['doc-1', 'doc-2'],
|
||||
)
|
||||
})
|
||||
|
||||
it('does not re-anchor when the RPC refused the delete', async () => {
|
||||
enqueue({ data: { id: 'je-1', source_type: 'manual', source_id: null } })
|
||||
enqueue({ data: [{ id: 'doc-1' }] })
|
||||
enqueue({ error: { message: 'Kan bara radera det sista verifikatet i serien.' } })
|
||||
|
||||
const { status } = await parseJsonResponse(await run())
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(reanchorOrphanedSupplierInvoiceDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes an empty list when the voucher had no documents', async () => {
|
||||
enqueue({ data: { id: 'je-1', source_type: 'manual', source_id: null } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: { deleted: true, voucher_series: 'A', voucher_number: 3 } })
|
||||
|
||||
const { status } = await parseJsonResponse(await run())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(reanchorOrphanedSupplierInvoiceDocuments).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
[],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||||
import { updateDraftEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { reanchorOrphanedSupplierInvoiceDocuments } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
|
||||
const logger = createLogger('journal-entries')
|
||||
|
||||
@@ -56,6 +57,19 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
// delete_last_voucher clears journal_entry_id on every document hanging on
|
||||
// the voucher (the FK is ON DELETE RESTRICT, so it has no choice). Capture
|
||||
// them first: a document that is a supplier invoice's retained source
|
||||
// document must be re-anchored to another posted verifikat of that invoice
|
||||
// afterwards, or the invoice's remaining verifikat is left showing the PDF
|
||||
// while every missing-underlag surface (which requires an anchored doc)
|
||||
// keeps warning "Underlag saknas" with no way for the user to resolve it.
|
||||
const { data: linkedDocs } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', id)
|
||||
|
||||
const { data, error } = await supabase.rpc('delete_last_voucher', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: id,
|
||||
@@ -77,6 +91,19 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
const orphanedDocIds = ((linkedDocs ?? []) as { id: string }[]).map((doc) => doc.id)
|
||||
const reanchored = await reanchorOrphanedSupplierInvoiceDocuments(
|
||||
supabase,
|
||||
companyId,
|
||||
orphanedDocIds,
|
||||
)
|
||||
if (reanchored > 0) {
|
||||
logger.info('re-anchored supplier invoice documents after voucher delete', {
|
||||
entryId: id,
|
||||
count: reanchored,
|
||||
})
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.deleted',
|
||||
payload: {
|
||||
|
||||
@@ -35,18 +35,12 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
mockCreateSupplierInvoiceCashEntry(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/core/documents/document-service')>(
|
||||
'@/lib/core/documents/document-service'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
linkToJournalEntry: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({
|
||||
anchorSupplierInvoiceDocument: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
@@ -252,7 +246,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cash method: links the inbox document to the cash payment verifikat (BFL 5 kap 6 §)', async () => {
|
||||
it('cash method: anchors the invoice document to a posted verifikat (BFL 5 kap 6 §)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
@@ -286,16 +280,17 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.journal_entry_id).toBe('je-cash')
|
||||
// The cash entry is the ONLY booking, so its underlag must hang on it.
|
||||
expect(linkToJournalEntry).toHaveBeenCalledWith(
|
||||
// The cash entry is the ONLY booking, so its underlag must hang on a
|
||||
// posted verifikat of this invoice. Which one it picks (and that it never
|
||||
// moves an already-anchored doc) is pinned in the helper's own tests.
|
||||
expect(anchorSupplierInvoiceDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'doc-1',
|
||||
'je-cash',
|
||||
'si-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('accrual method: does NOT re-link the document at payment (stays on the registration verifikat)', async () => {
|
||||
it('accrual method: still delegates the anchor check (a no-op once the doc sits on the registration verifikat)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
@@ -329,9 +324,15 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The document already lives on the registration verifikat: re-linking
|
||||
// here would move it off the primary booking.
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
// The document already lives on the registration verifikat, so the helper
|
||||
// leaves it there: it only ever anchors a FLOATING doc, which is the case
|
||||
// this route previously skipped entirely (leaving the payment verifikat
|
||||
// warning "Underlag saknas" with no way out).
|
||||
expect(anchorSupplierInvoiceDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'si-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 when journal entry creation fails (blocking: GL must succeed for payment)', async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -399,23 +399,14 @@ export const POST = withRouteContext(
|
||||
// Under kontantmetoden the cash payment entry is the ONLY booking of the
|
||||
// affärshändelse, so its underlag (the document from the inbox) must hang on
|
||||
// THIS verifikat per BFL 5 kap 6 §. Under faktureringsmetoden the document
|
||||
// is already linked to the registration verifikat at receipt: re-linking
|
||||
// here would move it off that primary booking, so we attach only for the
|
||||
// cash entry. Non-fatal: the payment is already committed and immutable, so
|
||||
// a link failure is logged and the invoice stays usable (mirrors the
|
||||
// registration-time linking in commitCreateSupplierInvoiceFromInbox).
|
||||
const invoiceDocumentId = (invoice as { document_id?: string | null }).document_id
|
||||
if (useCashEntry && invoiceDocumentId && journalEntryId) {
|
||||
try {
|
||||
await linkToJournalEntry(supabase, companyId!, invoiceDocumentId, journalEntryId)
|
||||
} catch (linkErr) {
|
||||
opLog.warn('failed to link supplier invoice document to cash payment JE', {
|
||||
documentId: invoiceDocumentId,
|
||||
journalEntryId,
|
||||
error: linkErr instanceof Error ? getUserErrorMessage(linkErr) : getUserErrorMessage(linkErr),
|
||||
})
|
||||
}
|
||||
}
|
||||
// is normally already linked to the registration verifikat at receipt, in
|
||||
// which case anchorSupplierInvoiceDocument is a no-op (it never moves an
|
||||
// anchored document). It only steps in when the document is floating, e.g.
|
||||
// attached after registration or orphaned by a deleted rättelse: without an
|
||||
// anchor every missing-underlag surface warns on a verifikat that plainly
|
||||
// shows the invoice. Non-fatal by construction: the payment is already
|
||||
// committed and immutable, so the helper logs and returns null on failure.
|
||||
await anchorSupplierInvoiceDocument(supabase, companyId!, id)
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-en
|
||||
import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
@@ -451,6 +452,13 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Same requirement one table over: the SUPPLIER INVOICE's own retained
|
||||
// document must sit on a posted verifikat, or the payment verifikat we
|
||||
// just booked shows the invoice PDF while every missing-underlag surface
|
||||
// (which only accepts an anchored doc) warns "Underlag saknas". No-op when
|
||||
// it is already anchored, e.g. on the registration verifikat.
|
||||
await anchorSupplierInvoiceDocument(supabase, companyId, supplier_invoice_id)
|
||||
|
||||
logMatchEvent(supabase, user.id, transactionId, 'matched', {
|
||||
supplierInvoiceId: supplier_invoice_id,
|
||||
matchConfidence: 1.0,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Re-anchor supplier-invoice source documents that are floating.
|
||||
--
|
||||
-- Problem (support case 2026-07-27, MGS Sweden): a verifikat booked from a
|
||||
-- supplier invoice showed the invoice PDF when opened, while the verifikat list
|
||||
-- kept warning "Underlag saknas" on the same row. Both surfaces were behaving
|
||||
-- as written:
|
||||
--
|
||||
-- * every missing-underlag surface (verifikat_without_documents /
|
||||
-- transactions_without_documents, /api/documents/counts, the transactions
|
||||
-- list) only accepts a referenced supplier-invoice document as underlag
|
||||
-- when it is ANCHORED (document_attachments.journal_entry_id IS NOT NULL),
|
||||
-- because only anchored docs sit behind the WORM deletion guards
|
||||
-- (block_document_deletion keys on journal_entry_id);
|
||||
-- * the verifikat view's reference resolver did not check the anchor and
|
||||
-- displayed the document regardless.
|
||||
--
|
||||
-- The resolver is aligned in the same change. This migration fixes the data
|
||||
-- half: documents that ended up floating even though the invoice still has a
|
||||
-- posted verifikat to hang on. Two production causes, both observed:
|
||||
--
|
||||
-- 1. delete_last_voucher clears journal_entry_id on every document attached
|
||||
-- to the voucher it tears down (the FK is ON DELETE RESTRICT, so it has
|
||||
-- no alternative). Deleting a rättelse the invoice's PDF had been
|
||||
-- relinked onto therefore orphans the PDF while the invoice's payment
|
||||
-- verifikat stays posted.
|
||||
-- 2. Payment/cash verifikat booked for an invoice whose document was never
|
||||
-- anchored at registration (attached after the fact, or booked through
|
||||
-- the API-key/MCP mark-paid path, which did not link it at all).
|
||||
--
|
||||
-- Anchoring is strictly protective: it moves the document behind the deletion
|
||||
-- guard, and NULL -> uuid is explicitly permitted by
|
||||
-- enforce_document_journal_entry_immutability (it returns early when
|
||||
-- OLD.journal_entry_id IS NULL). Scoped like the 20260724090000 §4 backfill:
|
||||
-- only currently-unlinked current-version documents (never steal a doc that
|
||||
-- already serves a verifikat), only into open, unlocked periods
|
||||
-- (enforce_period_lock_documents raises otherwise), and the target verifikat
|
||||
-- must be posted and belong to the same company as the document.
|
||||
--
|
||||
-- Preference order matches lib/core/documents/supplier-invoice-underlag.ts:
|
||||
-- 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 partial-payment verifikat, oldest first.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_updated integer;
|
||||
BEGIN
|
||||
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;
|
||||
|
||||
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
||||
RAISE NOTICE 're-anchored % floating supplier-invoice documents to a posted verifikat', v_updated;
|
||||
END;
|
||||
$$;
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user