Reviewing an invoice or a verifikat bilaga meant saving a file and opening it from the Downloads folder (user request, christian@odinaero.se 2026-07-25). - GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for in-browser review; anything else keeps the download behaviour every existing caller relies on. The filename still travels in the header, so the browser viewer's own save action produces the same name as the download button, and nosniff pins the content type. - The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner PDF". It resolves the document through the same resolveInvoicePdfSource path as the download, so preview cannot become the shortcut that presents a re-render as the invoice the customer received: the archived delivery wins, a re-render is shown with its caveat, and an unreadable delivery history still asks instead of guessing. The archive dialog now remembers whether the user asked to view or to save, and its fallback does that. - DocumentViewButton (supplier-invoice underlag, staged agent previews) points at the existing /api/documents/:id/inline proxy, so bilagor render in the browser. Navigation now happens straight from the click, so the signed-URL fetch and its popup-blocker workaround are gone. - The three re-render caveat strings and the two archive-dialog descriptions lose their "you downloaded" wording so they stay true for both actions; five new keys in sv + en. Tests: route cases for the default, inline and unknown disposition values; invoiceRerenderUrl cases for both modes and id encoding. npm test 11364 passed, lint 0 errors. Button row screenshotted against the design system (pill outline, Eye icon) via a temporary sandbox route. Closes #1190 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
5.6 KiB
TypeScript
159 lines
5.6 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 { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
|
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'
|
|
|
|
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
|
|
|
|
/**
|
|
* `?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'
|
|
}
|
|
|
|
function privateNoStore(response: NextResponse): NextResponse {
|
|
response.headers.set('Cache-Control', 'private, no-store')
|
|
return response
|
|
}
|
|
|
|
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 },
|
|
)
|
|
}
|
|
|
|
// 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 },
|
|
}))
|
|
}
|
|
|
|
// 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) },
|
|
)
|
|
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 = 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 }
|
|
)
|
|
}
|
|
},
|
|
)
|