Files
accounted/app/api/invoices/[id]/pdf/route.ts
T
MattssonandClaude Fable 5.1 9418de585f fix(invoices): say what is missing when an invoice preview cannot be rendered (#2303)
* fix(invoices): say what is missing when an invoice preview cannot be rendered

The PDF route already refuses with a structured envelope that names exactly
what the invoice lacks (no bankgiro, plusgiro, Swish or bank account for a
SEK invoice; no IBAN account for a foreign currency) and where to add it.
Two clients threw that away:

- The settings preview dialog (Inställningar -> Fakturering -> Förhandsvisa
  faktura) wrapped the envelope's inner object in new Error(), which
  stringified it to "[object Object]" and left only the generic "Kunde inte
  hantera fakturan. Försök igen." fallback. The parsed body now goes to the
  error mapper whole, with the invoice context and status.

- The invoice page's Förhandsgranska navigated a new tab straight to the
  re-render URL, so a 400 showed the raw JSON in that tab. The tab is now
  opened blank inside the click's activation window, the PDF is fetched
  first, and the tab gets the PDF as a blob URL or is closed again with the
  refusal in a toast. The archived delivery copy keeps the direct open.
  Ladda ner on the same page had a fixed "Kunde inte generera PDF" for
  re-render refusals and now maps the body the same way.

Regression test on the mapper covers the exact call shape the two surfaces
use and pins the old mangled shape as the fallback it produced.

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

* fix(invoices): probe the PDF route before opening the preview tab

Resolves the review findings on the first push in one pass.

Skeptic (correctness): the archived-copy branch still called window.open
with 'noopener', which returns null by spec even on success, so every
successful archived preview also fired the "popup blocked" toast (#1613
had the same defect). Both branches now go through openDeferredTab, which
opens with a real handle and severs the opener itself.

Skeptic (regression): serving the re-render as a blob URL lost the
Content-Disposition filename and gave the tab an address that dies on
reload. The route gains ?probe=1, which runs every refusal check and
answers 204 without rendering; the page probes first, shows a refusal as a
toast, and otherwise points the tab at the real inline URL. Filename,
reload and the single render are all kept. The blob URL is gone, which
also settles the compliance swarm's noopener and unrevoked-blob notes and
CodeRabbit's revoke request.

CodeRabbit: the probe fetch is bounded by AbortSignal.timeout so a stalled
route cannot leave a blank tab open, and the network-error mapper now
receives the active locale and invoice context.

Tests: route probe (204 without render, same 400 envelope as the render,
unknown value ignored) and the URL helper's probe flag.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:55:45 +02:00

192 lines
7.2 KiB
TypeScript

import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { PRIVATE_NO_STORE_HEADERS, privateNoStore } from '@/lib/api/private-no-store'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename, paymentConfirmationPdfFilename } from '@/lib/invoices/pdf-filename'
import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
/**
* `?disposition=inline` serves the PDF for in-browser review instead of forcing
* a download (#1190): reviewing an invoice should not mean opening a file from
* the Downloads folder. Anything else, including a missing or malformed value,
* keeps the download behaviour every existing caller relies on.
*/
function resolveDisposition(request: Request): 'inline' | 'attachment' {
const requested = new URL(request.url).searchParams.get('disposition')
return requested === 'inline' ? 'inline' : 'attachment'
}
/**
* `?variant=paid` asks for the betalningsbekräftelse (#1693): the same
* re-render, but refused unless the faktura is fully paid and named as a
* payment confirmation rather than as the invoice. It is always a fresh
* render; the archived original in the delivery history is never involved.
*/
function resolveVariant(request: Request): 'invoice' | 'paid' {
const requested = new URL(request.url).searchParams.get('variant')
return requested === 'paid' ? 'paid' : 'invoice'
}
/** `?probe=1` asks only whether the render would be refused; see the handler. */
function isProbe(request: Request): boolean {
return new URL(request.url).searchParams.get('probe') === '1'
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.pdf',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
// withRouteContext resolves companyId from the authenticated user's active
// membership. Explicit company filters remain mandatory defense in depth.
// Fetch invoice with customer and items
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 NextResponse.json(
{ error: 'Invoice not found' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const variant = resolveVariant(request)
if (variant === 'paid' && !isPaymentConfirmationEligible(invoice as Invoice)) {
return privateNoStore(errorResponseFromCode('INVOICE_PAYMENT_CONFIRMATION_NOT_PAID', log, {
requestId,
details: { currentStatus: (invoice as Invoice).status },
}))
}
// Fetch company settings
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return NextResponse.json(
{ error: 'Company settings not found' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: (invoice as Invoice).currency },
}))
}
// `?probe=1`: every refusal above has been checked, so answer without
// rendering. The in-app preview asks this first, from a fetch whose JSON
// refusal it can show as a message, and only then points the new tab at the
// real inline URL. Navigating the tab straight here showed the refusal
// envelope as raw JSON in that tab.
if (isProbe(request)) {
return new NextResponse(null, { status: 204, headers: PRIVATE_NO_STORE_HEADERS })
}
// Sort items by sort_order
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
// If this is a credit note, fetch the original invoice number
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: originalInvoice } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (originalInvoice) {
originalInvoiceNumber = originalInvoice.invoice_number
}
}
try {
// Generate PDF
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
(invoice as Invoice).currency,
{
paymentAccountRequired: invoiceRequiresPaymentAccount(invoice as Invoice),
payee: (invoice as Invoice).payment_details ?? null,
},
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, invoice as Invoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(invoice as Invoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer: invoice.customer as Customer,
items,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,
paymentLinkQrDataUrl,
})
)
// Convert Node.js Buffer to Uint8Array for Response
const uint8Array = new Uint8Array(pdfBuffer)
// Return PDF as response
const isCreditNote = !!invoice.credited_invoice_id
const filename = variant === 'paid'
? paymentConfirmationPdfFilename(invoice.invoice_number)
: invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: (invoice.customer as Customer).name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
return new NextResponse(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition(resolveDisposition(request), filename),
'Content-Length': pdfBuffer.length.toString(),
'Cache-Control': 'private, no-store',
// The filename is derived from company/customer/invoice data, so the
// Content-Type must not be re-sniffed from the bytes when the browser
// renders this inline.
'X-Content-Type-Options': 'nosniff',
},
})
} catch (error) {
log.error('invoice PDF generation failed', error, { requestId, invoiceId: id })
return NextResponse.json(
{ error: error instanceof Error ? getUserErrorMessage(error) : 'PDF generation failed' },
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
)
}
},
)