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
+6 -4
View File
@@ -959,13 +959,13 @@ async function commitSendInvoice(
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,
})
@@ -995,14 +995,16 @@ async function commitSendInvoice(
// after email delivery (line ~625); rendering with the stale 'draft' status
// would stamp the customer's PDF with "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,