Files
accounted/app/api/invoices/[id]/send/route.ts
T
MattssonandClaude Fable 5 0406e628e1 fix(settings): scope cross-field VAT validations to saves that touch them (#2121)
* fix(settings): scope cross-field VAT validations to saves that touch them

The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").

Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

* fix(invoices): gate issuance on the seller VAT number (skeptic finding)

The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).

Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.

Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
  periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.

The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths

Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.

Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

* test(email): refresh brand-mail snapshots for the coherent VAT fixture

The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:30:25 +02:00

715 lines
26 KiB
TypeScript

import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
issueCreditNote,
type CreditNoteOriginalInvoice,
} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import { withRouteContext } from '@/lib/api/with-route-context'
import { SendInvoiceSchema } from '@/lib/api/schemas'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import {
EMAIL_PATTERN,
exceedsInvoiceEmailRecipientLimit,
findAdditionalInvoiceRecipientCollisions,
invoiceEmailRecipientCount,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type {
AccountingMethod,
CompanySettings,
CreditNote,
Customer,
EntityType,
Invoice,
InvoiceItem,
} from '@/types'
ensureInitialized()
export const POST = withRouteContext(
'invoice.send',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ invoiceId: id })
// Optional body: user-edited issuance lines. Read here; validated after
// the ownership fetch below (no payload feedback for foreign invoices)
// but still long BEFORE the email leaves: after delivery the pipeline
// only degrades to PARTIAL. Same contract as mark-sent.
let rawBody: unknown
const bodyText = await request.text()
if (bodyText) {
try {
rawBody = JSON.parse(bodyText)
} catch {
// Malformed JSON must not silently fall back to generated lines.
return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 })
}
}
// The sandbox must never deliver a real email to a real customer: block
// the entire send pipeline (PDF render + Resend send + status flip).
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.email_send)
if (capBlocked) return capBlocked
const emailService = getEmailService()
if (!emailService.isConfigured()) {
return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId })
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
}
const isCreditNote = !!invoice.credited_invoice_id
const isCreditDeliveryRetry = isCreditNote && invoice.status === 'sent'
// A cancelled invoice keeps its F-series number for compliance with ML 17
// kap 24§ but is not a valid faktura: sending it would deliver a
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
// guard below for the more specific error message.
if (invoice.status === 'cancelled') {
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId })
}
// Only drafts may enter the send pipeline. The UI already hides Send for
// non-drafts, but a direct POST against an issued invoice would re-email
// the customer and post a SECOND revenue verifikat
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
// executor, which both reject non-drafts.
if (invoice.status !== 'draft' && !isCreditDeliveryRetry) {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: invoice.status },
})
}
const bodyResult = SendInvoiceSchema.safeParse(rawBody ?? {})
if (!bodyResult.success) {
opLog.warn('send validation failed')
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: bodyResult.error.flatten() },
{ status: 400 },
)
}
const linesResult = parseCustomIssuanceLines(
bodyResult.data.lines ? { lines: bodyResult.data.lines } : undefined,
)
if (!linesResult.ok) {
if (linesResult.error === 'invalid_body') {
opLog.warn('send validation failed')
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: linesResult.details },
{ status: 400 },
)
}
return errorResponseFromCode(
linesResult.error === 'unbalanced'
? 'INVOICE_MARK_SENT_LINES_UNBALANCED'
: 'INVOICE_MARK_SENT_LINES_INVALID',
opLog,
{ requestId, details: linesResult.details },
)
}
const customLines = linesResult.lines
// Custom lines only apply where send books inline; elsewhere they are
// deliberately ignored (documented in MarkInvoiceSentSchema). Logged for
// audit visibility instead of vanishing silently.
if (customLines && isCreditNote) {
opLog.warn('send: custom lines ignored (credit notes book via issueCreditNote)', {
lineCount: customLines.length,
})
}
const customer = invoice.customer as Customer
if (!customer.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
requestId,
details: { customerId: customer.id },
})
}
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId })
}
const invoiceCurrency = (invoice as Invoice).currency
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', opLog, {
requestId,
details: { currency: invoiceCurrency },
})
}
if (!hasRequiredSellerVatNumber(company as CompanySettings, invoice as Invoice)) {
return errorResponseFromCode('INVOICE_SEND_VAT_NUMBER_MISSING', opLog, { requestId })
}
const hasAdditionalRecipients =
(bodyResult.data.additional_cc?.length ?? 0) > 0
|| (bodyResult.data.additional_bcc?.length ?? 0) > 0
// Fixed recipients are owner/admin-approved company routing and apply to
// every writable sender. Only a new per-send disclosure needs this fresh
// role check. See .compliance/authorization-policy.md.
if (hasAdditionalRecipients) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
opLog.error('failed to authorize custom invoice recipients', membershipError)
return errorResponseFromCode('INTERNAL_ERROR', opLog, { requestId })
}
if (!membership || !['owner', 'admin'].includes(membership.role)) {
return errorResponseFromCode('FORBIDDEN', opLog, {
requestId,
details: { required_roles: ['owner', 'admin'] },
})
}
}
const recipientInput = {
to: customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
customerCc: customer.invoice_email_cc_addresses,
customerBcc: customer.invoice_email_bcc_addresses,
// This value comes from company settings or the authenticated sender. It
// is fixed routing, not an arbitrary request-controlled recipient.
legacyCc: company.email || user.email,
additionalCc: bodyResult.data.additional_cc,
additionalBcc: bodyResult.data.additional_bcc,
}
const recipientCollisions = findAdditionalInvoiceRecipientCollisions(recipientInput)
if (recipientCollisions.length > 0) {
return errorResponseFromCode('VALIDATION_ERROR', opLog, {
requestId,
details: { field: 'recipients', collisions: recipientCollisions },
})
}
const recipients = resolveInvoiceEmailRecipients(recipientInput)
if (recipients.to.length === 0) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
requestId,
details: { customerId: customer.id },
})
}
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
return errorResponseFromCode('INVOICE_SEND_TOO_MANY_RECIPIENTS', opLog, {
requestId,
details: { recipient_count: invoiceEmailRecipientCount(recipients) },
})
}
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
let originalInvoice: CreditNoteOriginalInvoice | undefined
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: original } = await supabase
.from('invoices')
.select('id, invoice_number, external_invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (!original) {
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', opLog, { requestId })
}
originalInvoice = original as CreditNoteOriginalInvoice
// Self-billed originals carry their number in external_invoice_number
// (invoice_number is null by design); without the fallback the
// credit-note PDF loses its ML 17 kap 22 reference to the original
// (issue #1820).
originalInvoiceNumber =
original.invoice_number ?? original.external_invoice_number ?? undefined
}
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
// number. If the row is already numbered (retry path), skip: we'd just
// render twice for no gain.
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
const preflight = await prepareInvoicePdfRender(
company as CompanySettings,
(invoice as Invoice).currency,
{ paymentAccountRequired },
)
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
customer,
items,
company: preflight.company,
originalInvoiceNumber,
branding: preflight.branding,
}),
)
} catch (err) {
opLog.error('preflight PDF render failed before invoice number assignment', err as Error)
return errorResponseFromCode('INVOICE_SEND_PDF_RENDER_FAILED', opLog, { requestId })
}
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId: companyId!,
userId: user.id,
invoiceId: id,
})
} catch (err) {
opLog.error('failed to reserve invoice delivery before number assignment', err as Error)
return errorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', opLog, {
requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
// Allocate the F-series number. Idempotent: retries reuse the same number.
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
opLog.error('failed to assign invoice number on send', err as Error)
return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId })
}
// Auto-create an online payment link (extension-provided, e.g. Stripe) now
// that the number exists, so the email button and PDF QR carry it. A
// failure never blocks the send: the faktura is legally valid without a
// link, so it degrades to a PARTIAL warning instead.
const { failure: paymentLinkFailure } = isCreditNote
? { failure: undefined }
: await applyPaymentLinkToInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
opLog,
)
// Final render with the assigned number: this is the buffer attached to
// the email and later archived as underlag. Override status to 'sent' on
// the in-memory copy: the DB flip happens after email delivery (line
// ~185), but if we render with the stale 'draft' status the customer
// receives a PDF stamped "UTKAST: inte en giltig faktura".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
renderableInvoice.currency,
{ paymentAccountRequired },
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
customer,
items,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,
paymentLinkQrDataUrl,
}),
)
const emailData = {
invoice: renderableInvoice,
customer,
company: company as CompanySettings,
}
const filename = invoicePdfFilename({
companyName: company.company_name,
customerName: customer.name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
const partialFailures: Array<{ step: string; reason: string }> = []
if (paymentLinkFailure) {
// The failure string is a raw provider/DB message: log it, but the
// response field is user-visible and must stay Swedish (issue #337).
opLog.warn('payment link creation failed on send', { reason: paymentLinkFailure })
partialFailures.push({
step: 'payment_link',
reason: 'Betalningslänken kunde inte skapas. Fakturan skickades utan betalningslänk.',
})
}
let statusFlipped = isCreditDeliveryRetry
let creditJournalEntryId: string | null = null
// Credit notes must be fully issued and booked before delivery. The CAS is
// the single-winner lock; the idempotent issue service can repair any
// immutable entry that committed before a later database step failed.
if (isCreditNote && originalInvoice) {
if (!isCreditDeliveryRetry) {
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
opLog.error('credit note status update failed before issue', updateError)
return errorResponseFromCode('INVOICE_CREDIT_ISSUE_INCOMPLETE', opLog, {
requestId,
details: { failure_steps: ['status_update'] },
})
}
if (!flipRows || flipRows.length === 0) {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: 'sent' },
})
}
statusFlipped = true
}
const issueResult = await issueCreditNote({
supabase,
companyId: companyId!,
userId: user.id,
creditNote: invoice as CreditNote,
originalInvoice,
entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma',
accountingMethod: ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod,
log: opLog,
})
creditJournalEntryId = issueResult.journalEntryId
if (!issueResult.complete) {
if (issueResult.journalEntryRequired && !issueResult.journalEntryId) {
await supabase
.from('invoices')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.is('journal_entry_id', null)
}
return errorResponseFromCode(
issueResult.repairRequired
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
opLog,
{
requestId,
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
},
)
}
}
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase,
emailService,
companyId: companyId!,
userId: user.id,
invoiceId: id,
deliveryId,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name,
from: await resolveInvoiceSender(supabase, companyId!, company.company_name),
filename,
pdfBuffer,
})
} catch (err) {
opLog.error('failed to persist invoice delivery snapshot before send', err as Error)
return errorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', opLog, {
requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
if (!result.success) {
if (result.trackingWarning) {
opLog.warn('invoice send: failed delivery snapshot not reconciled', {
invoiceId: id,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
requestId,
details: { retryable: true },
})
}
if (result.trackingWarning) {
opLog.warn('invoice send: delivery snapshot not finalised', {
invoiceId: id,
deliveryId: result.deliveryId,
})
partialFailures.push({
step: 'delivery_history',
reason: 'Utskicket sparades men kunde inte färdigmarkeras i historiken.',
})
}
// From here on the invoice has reached the customer. Failures in the
// follow-up steps degrade the response to PARTIAL: the user gets a
// success toast with a sub-warning, and the audit trail records exactly
// which sub-step broke.
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
// pass the draft guard above and both email the customer, but only the
// request that wins this compare-and-set runs the bookkeeping steps
// below: the loser would otherwise post a duplicate revenue verifikat
// and archive the PDF twice. PostgREST returns no error for a 0-row
// update, so the row count via .select('id') is the actual lock signal.
// A genuine update error also skips the follow-ups: the row is still
// 'draft', so a later retry re-runs the whole pipeline and ends with
// exactly one journal entry (at the cost of a duplicate email).
if (!isCreditNote) {
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
opLog.warn('failed to update invoice status to sent', updateError)
partialFailures.push({
step: 'status_update',
reason: 'Fakturans status kunde inte uppdateras till skickad.',
})
} else if (!flipRows || flipRows.length === 0) {
opLog.warn('invoice already flipped to sent by a concurrent request; skipping bookkeeping follow-ups')
partialFailures.push({
step: 'status_update',
reason: 'Fakturan skickades samtidigt av en annan begäran; bokföringen hanterades där.',
})
} else {
statusFlipped = true
}
}
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined
// #967: deferred companies send WITHOUT booking; ekonomi books later via
// POST /api/invoices/[id]/book. The invoice then legitimately sits at
// journal_entry_id = NULL until then, like under kontantmetoden.
if (
customLines &&
!isCreditNote &&
(!isRealInvoice || !booksInvoicesOnIssue(company as CompanySettings))
) {
opLog.warn('send: custom lines ignored (not on accrual book-at-issue path)', {
lineCount: customLines.length,
})
}
if (statusFlipped && !isCreditNote && isRealInvoice && booksInvoicesOnIssue(company as CompanySettings)) {
try {
if (customLines) {
// Audit trail: distinguish user-edited bookings from generated ones.
opLog.info('send: booking user-edited custom lines', {
userId: user.id,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
undefined,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
)
if (journalEntry) {
createdJournalEntryId = journalEntry.id
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
// Periodiserade lines: create their schedules + catch-up
// dissolutions now that the revenue entry exists. Failures degrade
// to PARTIAL: the entry is committed and must not be rolled back.
// Skipped for user-edited lines: the generated 29xx deferral may
// not exist in what was booked; edited lines book as reviewed.
if (!customLines) {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
}
} catch (err) {
opLog.error('failed to create invoice journal entry on send', err as Error)
partialFailures.push({
step: 'journal_entry',
reason: 'Fakturans verifikat kunde inte skapas.',
})
}
}
if (statusFlipped && isRealInvoice && createdJournalEntryId) {
try {
await linkToJournalEntry(
supabase,
companyId!,
result.documentId,
createdJournalEntryId,
)
} catch (err) {
opLog.error('failed to link archived invoice PDF to journal entry', err as Error)
partialFailures.push({
step: 'pdf_link',
reason: 'Fakturans arkiverade PDF kunde inte kopplas till verifikationen.',
})
}
}
// Gated like the steps above: on a lost race the winning request emits
// it; on a flip error the row is still 'draft', so emitting would
// contradict DB state and the retry emits it instead.
if (statusFlipped && !isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
})
}
if (partialFailures.length > 0) {
opLog.warn('invoice sent with partial follow-up failures', {
errorCode: 'INVOICE_SEND_PARTIAL',
failures: partialFailures,
})
}
opLog.info('invoice sent', {
deliveryId: result.deliveryId,
messageId: result.messageId,
recipientCounts: {
to: recipients.to.length,
cc: recipients.cc.length,
},
})
return NextResponse.json(
{
success: true,
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email}`,
messageId: result.messageId,
deliveryId: result.deliveryId,
recipient_counts: {
to: recipients.to.length,
cc: recipients.cc.length,
},
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
},
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
{ requireWrite: true },
)