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>
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
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')
|
|
|
|
ensureInitialized()
|
|
|
|
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'bookkeeping.journal_entry.get',
|
|
async (_request, { supabase, companyId }, { params }) => {
|
|
const { id } = await params
|
|
|
|
const { data, error } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (error) {
|
|
// PostgREST's "no rows" message is raw English; the user just needs to
|
|
// know the verifikat is gone (matches JOURNAL_ENTRY_NOT_FOUND registry).
|
|
return NextResponse.json(
|
|
{ error: 'Verifikationen kunde inte hittas.' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
},
|
|
)
|
|
|
|
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'bookkeeping.journal_entry.delete',
|
|
async (_request, { supabase, companyId, user }, { params }) => {
|
|
const { id } = await params
|
|
|
|
// Read source_type/source_id BEFORE deleting so we can revert the linked
|
|
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
|
|
// delete_last_voucher but the invoice's paid status lives outside the GL
|
|
// and would otherwise stay stuck on "paid" after the user deletes the
|
|
// payment voucher.
|
|
const { data: entryBefore } = await supabase
|
|
.from('journal_entries')
|
|
.select('id, source_type, source_id')
|
|
.eq('id', id)
|
|
.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,
|
|
})
|
|
|
|
if (error) {
|
|
logger.error('delete_last_voucher failed', { entryId: id, error })
|
|
return NextResponse.json(
|
|
{ error: getErrorMessage(error, { context: 'journal_entry', statusCode: 400 }) },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (entryBefore) {
|
|
try {
|
|
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, entryBefore)
|
|
} catch (syncError) {
|
|
logger.warn('payment status sync failed after delete', { entryId: id, error: syncError })
|
|
}
|
|
}
|
|
|
|
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: {
|
|
entryId: id,
|
|
voucherSeries: data.voucher_series,
|
|
voucherNumber: data.voucher_number,
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
|
|
return NextResponse.json({ data })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
/**
|
|
* PATCH: edit a DRAFT verifikat in place (header + lines). Only drafts are
|
|
* editable; updateDraftEntry rejects committed entries with a 409, and the DB
|
|
* immutability trigger is the backstop.
|
|
*/
|
|
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'bookkeeping.journal_entry.update',
|
|
async (request, { supabase, companyId, user }, { params }) => {
|
|
const { id } = await params
|
|
const validation = await validateBody(request, CreateJournalEntrySchema)
|
|
if (!validation.success) return validation.response
|
|
|
|
try {
|
|
const entry = await updateDraftEntry(supabase, companyId, user.id, id, validation.data)
|
|
return NextResponse.json({ data: entry })
|
|
} catch (err) {
|
|
const typed = bookkeepingErrorResponse(err)
|
|
if (typed) return typed
|
|
// Untyped errors map to Swedish via getErrorMessage: the raw message is
|
|
// logged here and must never reach the user verbatim (issue #337).
|
|
logger.error('failed to update draft journal entry', { entryId: id, error: err })
|
|
return NextResponse.json(
|
|
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
},
|
|
{ requireWrite: true },
|
|
)
|