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 } ) } })