fix(invoices): embed company logo as PNG so it renders on invoice PDFs (#772) (#776)

* fix(invoices): embed company logo as PNG so it renders on invoice PDFs (#772)

@react-pdf/renderer's <Image> only decodes JPG/PNG, but the logo upload route
and the `logos` bucket also accept SVG and WebP. For an SVG/WebP logo @react-pdf
silently swallows the decode error (console.warn inside a try/catch in its
fetchImage step), so the invoice renders with NO logo and nothing surfaces —
"Logotyp kommer inte med på fakturor".

Fix: prepareInvoicePdfRender now fetches the stored logo and re-encodes it to a
PNG data URL via sharp (SVGs rasterized at higher density), handing the template
a company whose logo_url is that data URL. Renders regardless of upload format
and removes the render-time dependency on a remote fetch inside @react-pdf.
Falls back to the original URL unchanged on any failure (network, unreadable
image, sharp unavailable), so behaviour is never worse than before. Result is
cached per logo URL (5-min TTL, bounded to 50) since the logo is re-rendered on
every invoice — twice per send and once per invoice in recurring/batch loops.

prepareInvoicePdfRender becomes async and returns the resolved { branding,
company }; all 8 call sites updated (6 routes, recurring-schedule-service,
pending-operations/commit) to await it and pass the resolved company. Layered
cleanly on top of the Swish-QR feature already on main — both coexist at every
call site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoices): bound and dedupe the logo fetch (review hardening)

Review (PR Agent security): resolveLogoDataUrl fetched logo_url with no timeout
or size limit. Add a 5s AbortSignal.timeout and a 5 MB cap (checked on the
declared content-length and the read body) so a slow/oversized logo host can't
hang or balloon an invoice render. SSRF itself isn't reachable today — logo_url
is only ever set to a Supabase logos-bucket URL by the upload route — so an
origin allowlist is intentionally skipped (would break self-hosted storage).

Also coalesce concurrent renders of the same logo (preflight+final on a send,
and recurring/batch loops) onto one in-flight fetch+encode instead of N. New
test covers the size-cap fallback; existing SVG test now asserts the timeout
signal. 9/9 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-25 13:42:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 739f18fd1c
commit 10a0b1d8dd
10 changed files with 350 additions and 26 deletions
+4 -2
View File
@@ -169,14 +169,16 @@ export async function POST(
// is stale and still reads 'draft' — override here so the archived
// underlag isn't stamped "UTKAST – inte en giltig faktura".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding } = prepareInvoicePdfRender(settings as CompanySettings)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
settings as CompanySettings,
)
const swishQrDataUrl = await buildSwishQrDataUrl(settings as CompanySettings, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
customer: invoice.customer as Customer,
items,
company: settings as CompanySettings,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,
+4 -2
View File
@@ -67,14 +67,16 @@ export async function GET(
try {
// Generate PDF
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer: invoice.customer as Customer,
items,
company: company as CompanySettings,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,
+6 -4
View File
@@ -101,13 +101,13 @@ export const POST = withRouteContext(
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
const preflight = prepareInvoicePdfRender(company as CompanySettings)
const preflight = await prepareInvoicePdfRender(company as CompanySettings)
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
customer,
items,
company: company as CompanySettings,
company: preflight.company,
originalInvoiceNumber,
branding: preflight.branding,
}),
@@ -132,14 +132,16 @@ export const POST = withRouteContext(
// ~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 } = prepareInvoicePdfRender(company as CompanySettings)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
customer,
items,
company: company as CompanySettings,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,
+4 -2
View File
@@ -172,13 +172,15 @@ export async function POST(request: Request) {
} as Invoice
try {
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
customer,
items: invoiceItems,
company: company as CompanySettings,
company: renderCompany,
isPreview: true,
branding,
})
@@ -149,14 +149,16 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
let pdfBuffer: Buffer
try {
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, typed as Invoice)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: typed as Invoice,
customer: typed.customer as Customer,
items,
company: company as CompanySettings,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,
@@ -271,13 +271,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const isFreshAllocation = !typed.invoice_number
if (isFreshAllocation) {
try {
const preflight = prepareInvoicePdfRender(settings)
const preflight = await prepareInvoicePdfRender(settings)
await renderToBuffer(
InvoicePDF({
invoice: { ...(typed as Invoice), invoice_number: 'F-PREVIEW' },
customer,
items,
company: settings,
company: preflight.company,
originalInvoiceNumber,
branding: preflight.branding,
}),
@@ -361,14 +361,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let pdfBuffer: Buffer
try {
const { branding } = prepareInvoicePdfRender(settings)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(settings)
const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
customer,
items,
company: settings,
company: renderCompany,
originalInvoiceNumber,
branding,
swishQrDataUrl,