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