Files
accounted/app/api/invoices/preview-pdf/route.ts
T
MattssonandClaude Fable 5 00ae3540db feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration

Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical
DTOs, customer APIs (web + v1) and invoice send flows so contact person and
customer-level invoice CC/BCC addresses survive provider migrations. NULL
means unconfigured and empty means an explicit clear, so re-syncs enrich
legacy gaps without resurrecting deliberately removed values. Fortnox fixed
assets are split into a dedicated follow-up issue.

Fixes #1345

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): bump customer metadata migration past pack-slug version

Main already contains 20260803230000; keep new versions strictly newest so
Supabase branching applies them in order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(customers): complete Customer type consumers and make enrichment payload resolvable

The preview-pdf mock customer and the makeCustomer fixture now carry the
three new metadata fields, fixing the type-check failure in Build (zero
extensions) and Vercel.

The enrichment update in the migration orchestrator now spells its payload
as an object literal typed CustomerMetadataEnrichment (absent keys drop at
serialization), so the phantom-column guard resolves the columns instead of
counting another unresolvable dynamic payload past its ceiling. The cc/bcc
guards also verify element types instead of casting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:00:03 +02:00

275 lines
9.8 KiB
TypeScript

import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getVatRules } from '@/lib/invoices/vat-rules'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/**
* POST /api/invoices/preview-pdf
*
* Generates a preview PDF from form data without creating an invoice.
* Returns the PDF as an inline blob for display in a new browser tab.
*/
export const POST = withRouteContext('invoice.preview_pdf', async (request, {
supabase,
user,
companyId,
log,
requestId,
}) => {
const body = await request.json()
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body
// Preview-only https gate, mirroring CreateInvoiceSchema: the value is
// rendered as a clickable link + QR in the preview PDF.
const previewPaymentLink = (() => {
if (typeof payment_link_url !== 'string' || !payment_link_url.trim()) return null
try {
return new URL(payment_link_url).protocol === 'https:' ? payment_link_url.trim() : null
} catch {
return null
}
})()
if (!items || items.length === 0) {
return NextResponse.json(
{ error: 'Rader krävs' },
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const docType: InvoiceDocumentType = document_type || 'invoice'
const requestedCurrency = currency || 'SEK'
// Fetch and validate company payment settings before customer data. The
// preview performs no writes, but a request that cannot be rendered should
// still stop before processing customer details.
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return NextResponse.json(
{ error: 'Företagsinställningar saknas' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, {
currency: requestedCurrency,
document_type: docType,
credited_invoice_id: null,
})) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: requestedCurrency },
}))
}
// When customer_id is omitted, only allow the synthetic preview if the
// company has no real customers: this is the settings-preview dead-end
// case. Derived server-side so a client can't bypass the ownership check
// by passing a flag.
const isMockCustomer = !customer_id
let customer: Customer
if (isMockCustomer) {
const { count, error: countError } = await supabase
.from('customers')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
if (countError || (count ?? 0) > 0) {
return NextResponse.json(
{ error: 'Kunduppgifter krävs' },
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const nowIso = new Date().toISOString()
customer = {
id: 'preview-customer',
user_id: 'preview-user',
company_id: 'preview-company',
name: 'Exempel AB',
customer_type: 'swedish_business',
customer_number: null,
email: 'kund@exempel.se',
phone: null,
address_line1: 'Storgatan 1',
address_line2: null,
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
org_number: '556677-8899',
vat_number: null,
vat_number_validated: false,
vat_number_validated_at: null,
personal_number: null,
contact_person: null,
invoice_email_cc_addresses: null,
invoice_email_bcc_addresses: null,
language: 'sv',
default_payment_terms: 30,
notes: null,
created_at: nowIso,
updated_at: nowIso,
}
} else {
const { data, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('company_id', companyId)
.single()
if (customerError || !data) {
return NextResponse.json(
{ error: 'Kunden hittades inte' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
customer = data as Customer
}
// VAT rules are customer-type-driven and only know the customer side.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const isDeliveryNote = docType === 'delivery_note'
// VAT registration gate: mirror the server-side write gate
// (lib/invoices/build-invoice-write.ts) so the preview never shows output VAT
// for a non-momsregistrerad seller. Without this the per-item fallback below
// (`?? vatRules.rate`) would render 25% for a Swedish customer even though the
// created invoice books no VAT, misleading the user at the review step.
const notVatRegistered = (company as { vat_registered?: boolean }).vat_registered === false
const zeroVat = notVatRegistered && !isDeliveryNote
// Build items with line totals and per-item VAT
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => {
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
const rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate)
return {
id: `preview-${index}`,
invoice_id: 'preview',
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: rate,
vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100,
created_at: new Date().toISOString(),
}
})
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
const vatAmount = isDeliveryNote ? 0 : invoiceItems.reduce((sum, item) => sum + item.vat_amount, 0)
const total = isDeliveryNote ? 0 : subtotal + vatAmount
// Derive vat_rate from items: single rate → that rate, mixed → null
const itemRates = new Set(invoiceItems.map((item) => item.vat_rate))
const effectiveVatRate = isDeliveryNote ? 0 : (itemRates.size === 1 ? itemRates.values().next().value! : null)
// Construct a temporary Invoice-like object
const previewInvoice = {
id: 'preview',
user_id: isMockCustomer ? 'preview-user' : user.id,
customer_id: customer.id,
invoice_number: typeof invoice_number === 'string' && invoice_number.trim()
? invoice_number
: isMockCustomer ? '1' : null,
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
status: 'draft',
currency: requestedCurrency,
exchange_rate: null,
exchange_rate_date: null,
subtotal: isDeliveryNote ? 0 : subtotal,
subtotal_sek: null,
vat_amount: vatAmount,
vat_amount_sek: null,
total,
total_sek: null,
vat_treatment: vatRules.treatment,
vat_rate: effectiveVatRate,
moms_ruta: vatRules.momsRuta,
your_reference: your_reference || null,
our_reference: our_reference || null,
notes: notes || null,
payment_link_url: previewPaymentLink,
reverse_charge_text: vatRules.reverseChargeText || null,
credited_invoice_id: null,
document_type: docType,
converted_from_id: null,
paid_at: null,
paid_amount: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Invoice
try {
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
previewInvoice.currency,
{ paymentAccountRequired: invoiceRequiresPaymentAccount(previewInvoice) },
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, previewInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(previewInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
customer,
items: invoiceItems,
company: renderCompany,
isPreview: true,
branding,
swishQrDataUrl,
paymentLinkQrDataUrl,
})
)
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: customer.name,
invoiceNumber: previewInvoice.invoice_number,
invoiceId: previewInvoice.id,
invoiceDate: previewInvoice.invoice_date,
documentType: previewInvoice.document_type,
})
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('inline', filename),
'Cache-Control': 'private, no-store',
},
})
} catch (error) {
log.error('invoice preview PDF generation failed', error, { requestId })
return NextResponse.json(
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
)
}
})