* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates
202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
|
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
|
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
|
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
|
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
|
import type { CompanySettings, EntityType, Invoice, InvoiceItem } from '@/types'
|
|
|
|
// Statuses where the revenue entry can still be created afterwards. Paid
|
|
// invoices are excluded: their payment flow has already booked the full
|
|
// cash-style entry (mark-paid routes on the missing journal-entry link), so
|
|
// booking the sale now would double-post revenue.
|
|
const BOOKABLE_STATUSES = ['sent', 'overdue']
|
|
|
|
/**
|
|
* POST /api/invoices/[id]/book
|
|
*
|
|
* The explicit "Bokför" step for companies with defer_invoice_booking (#967):
|
|
* one person creates and sends the invoice without bookkeeping, ekonomi books
|
|
* the revenue entry here once the kontering is verified.
|
|
*/
|
|
export const POST = withRouteContext(
|
|
'invoice.book',
|
|
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { user, supabase, companyId, log, requestId } = ctx
|
|
|
|
const { data: invoice } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(name), items:invoice_items(*)')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (!invoice) {
|
|
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
|
|
}
|
|
if (invoice.journal_entry_id) {
|
|
return errorResponseFromCode('INVOICE_BOOK_ALREADY_BOOKED', log, { requestId })
|
|
}
|
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
|
if (!isRealInvoice || invoice.credited_invoice_id) {
|
|
return errorResponseFromCode('INVOICE_BOOK_NOT_BOOKABLE', log, { requestId })
|
|
}
|
|
if (!BOOKABLE_STATUSES.includes(invoice.status)) {
|
|
return errorResponseFromCode('INVOICE_BOOK_INVALID_STATUS', log, {
|
|
requestId,
|
|
details: { currentStatus: invoice.status },
|
|
})
|
|
}
|
|
|
|
// The revenue-at-issue entry is a faktureringsmetoden concept; under
|
|
// kontantmetoden the sale is booked in full when it is paid.
|
|
const { data: settings, error: settingsError } = await supabase
|
|
.from('company_settings')
|
|
.select('accounting_method, entity_type')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
// Fail closed: booking with guessed settings could apply the wrong
|
|
// method's or entity type's rules, so a failed/missing settings read aborts.
|
|
if (settingsError || !settings) {
|
|
log.error('failed to load company settings for deferred booking', settingsError ?? undefined, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
|
|
}
|
|
if ((settings.accounting_method || 'accrual') !== 'accrual') {
|
|
return errorResponseFromCode('INVOICE_BOOK_CASH_METHOD', log, { requestId })
|
|
}
|
|
const entityType = ((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
|
|
|
|
let journalEntry
|
|
try {
|
|
journalEntry = await createInvoiceJournalEntry(
|
|
supabase,
|
|
companyId!,
|
|
user.id,
|
|
invoice as Invoice,
|
|
entityType,
|
|
invoice.customer?.name,
|
|
)
|
|
} catch (err) {
|
|
if (isBookkeepingError(err)) {
|
|
return errorResponse(err, log, { requestId })
|
|
}
|
|
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
|
|
}
|
|
|
|
// Returns null ONLY when no fiscal period covers invoice_date (other
|
|
// failures throw). Nothing was posted, so a plain error is safe.
|
|
if (!journalEntry) {
|
|
return errorResponseFromCode('INVOICE_BOOK_NO_FISCAL_PERIOD', log, {
|
|
requestId,
|
|
details: { invoiceDate: invoice.invoice_date },
|
|
})
|
|
}
|
|
|
|
// CAS-guarded link: only claim the invoice if it is still unbooked, still
|
|
// in a bookable status, and still uncredited. A concurrent
|
|
// book/mark-paid/credit that got there first would otherwise leave this
|
|
// entry double-posting revenue, so cancel it.
|
|
const { data: linked, error: linkError } = await supabase
|
|
.from('invoices')
|
|
.update({ journal_entry_id: journalEntry.id })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.is('journal_entry_id', null)
|
|
.in('status', BOOKABLE_STATUSES)
|
|
.is('credited_invoice_id', null)
|
|
.select()
|
|
.single()
|
|
|
|
if (linkError || !linked) {
|
|
await cancelOrphanedPaymentEntry(
|
|
supabase,
|
|
companyId!,
|
|
user.id,
|
|
journalEntry.id,
|
|
'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
|
|
)
|
|
return errorResponseFromCode('INVOICE_BOOK_CONFLICT', log, { requestId })
|
|
}
|
|
|
|
const warnings: Array<{ code: string; message: string }> = []
|
|
|
|
// The send flow archived the exact delivered PDF before this deferred
|
|
// journal entry existed. Attach the newest successful delivery snapshot now.
|
|
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
|
|
'latest_sent_invoice_delivery_document',
|
|
{ p_company_id: companyId, p_invoice_id: id },
|
|
)
|
|
|
|
if (deliveryDocumentError) {
|
|
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
|
|
invoiceId: id,
|
|
})
|
|
warnings.push({
|
|
code: 'PDF_LINK_FAILED',
|
|
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
|
})
|
|
} else if (typeof deliveryDocumentId === 'string') {
|
|
try {
|
|
await linkToJournalEntry(
|
|
supabase,
|
|
companyId!,
|
|
deliveryDocumentId,
|
|
journalEntry.id,
|
|
)
|
|
} catch (err) {
|
|
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
|
|
invoiceId: id,
|
|
documentId: deliveryDocumentId,
|
|
})
|
|
warnings.push({
|
|
code: 'PDF_LINK_FAILED',
|
|
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Periodiseringar ride on the revenue entry, so they can only be created
|
|
// now. Non-blocking: the entry is committed (immutable); a schedule
|
|
// failure is surfaced as a warning and retried from the periodiseringar
|
|
// page.
|
|
try {
|
|
const accrual = await createSchedulesForCustomerInvoice(
|
|
supabase,
|
|
companyId!,
|
|
user.id,
|
|
invoice as Invoice,
|
|
(invoice.items as InvoiceItem[] | null) ?? [],
|
|
journalEntry.id,
|
|
entityType,
|
|
)
|
|
if (accrual.failed > 0) {
|
|
warnings.push({
|
|
code: 'ACCRUAL_SCHEDULE_FAILED',
|
|
message:
|
|
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
|
|
'skapas. Kontrollera under Bokföring → Periodiseringar.',
|
|
})
|
|
}
|
|
} catch (err) {
|
|
log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id })
|
|
warnings.push({
|
|
code: 'ACCRUAL_SCHEDULE_FAILED',
|
|
message:
|
|
'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' +
|
|
'Kontrollera under Bokföring → Periodiseringar.',
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: linked,
|
|
journal_entry_id: journalEntry.id,
|
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|