diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index c7c455c8..28881174 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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, diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 1ea47d7c..d397efc9 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -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, diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index fdc2afdc..7d8cc18a 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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, diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index ac9c8c0c..fb12ac06 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -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, }) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts index b13bbfc9..45ba8c1c 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 6182c375..fb7ba17a 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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, diff --git a/lib/invoices/__tests__/pdf-render-helpers.test.ts b/lib/invoices/__tests__/pdf-render-helpers.test.ts new file mode 100644 index 00000000..42e3c896 --- /dev/null +++ b/lib/invoices/__tests__/pdf-render-helpers.test.ts @@ -0,0 +1,179 @@ +/** + * Regression tests for issue #772 — "Logotyp kommer inte med på fakturor". + * + * Root cause: @react-pdf/renderer's only decodes JPG/PNG, but the logo + * upload route and the `logos` bucket accept SVG and WebP. When a logo was an + * SVG/WebP, @react-pdf silently dropped it (it swallows the decode error in a + * try/catch), so invoices rendered with no logo and no error. + * + * Fix: prepareInvoicePdfRender fetches the stored logo and re-encodes it to a + * PNG data URL via sharp, so every supported upload format renders. These tests + * mock `fetch` and exercise the real sharp pipeline. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import sharp from 'sharp' +import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { makeCompanySettings } from '@/tests/helpers' + +const PNG_DATA_URL_PREFIX = 'data:image/png;base64,' + +const SVG_LOGO = Buffer.from( + '' + + '' + + 'ACME', +) + +/** Build a one-shot fetch mock that returns the given bytes + content-type. */ +function mockFetchOnce(buf: Buffer, contentType: string) { + const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + const fn = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => contentType }, + arrayBuffer: async () => arrayBuffer, + }) + vi.stubGlobal('fetch', fn) + return fn +} + +/** A data: URL whose payload decodes to a valid PNG via sharp. */ +async function expectValidEmbeddedPng(logoUrl: string | null | undefined) { + expect(logoUrl).toMatch(new RegExp(`^${PNG_DATA_URL_PREFIX}`)) + const base64 = (logoUrl as string).slice(PNG_DATA_URL_PREFIX.length) + const meta = await sharp(Buffer.from(base64, 'base64')).metadata() + expect(meta.format).toBe('png') +} + +describe('prepareInvoicePdfRender — logo resolution (issue #772)', () => { + beforeEach(() => { + vi.unstubAllGlobals() + }) + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('embeds an SVG logo as a PNG data URL so @react-pdf can draw it', async () => { + const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml') + const company = makeCompanySettings({ + logo_url: 'https://example.test/svg-logo-1.svg', + }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + // Fetched with a timeout signal so a slow logo host can't hang the render. + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.test/svg-logo-1.svg', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + await expectValidEmbeddedPng(resolved.logo_url) + }) + + it('embeds a WebP logo as a PNG data URL', async () => { + const webp = await sharp(SVG_LOGO).webp().toBuffer() + mockFetchOnce(webp, 'image/webp') + const company = makeCompanySettings({ + logo_url: 'https://example.test/webp-logo-1.webp', + }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + await expectValidEmbeddedPng(resolved.logo_url) + }) + + it('re-encodes a PNG logo to an embedded data URL (no remote fetch at render time)', async () => { + const png = await sharp(SVG_LOGO).png().toBuffer() + mockFetchOnce(png, 'image/png') + const company = makeCompanySettings({ + logo_url: 'https://example.test/png-logo-1.png', + }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + await expectValidEmbeddedPng(resolved.logo_url) + }) + + it('falls back to the original URL when the logo fetch is not ok', async () => { + const fn = vi.fn().mockResolvedValue({ + ok: false, + headers: { get: () => null }, + arrayBuffer: async () => new ArrayBuffer(0), + }) + vi.stubGlobal('fetch', fn) + const url = 'https://example.test/missing-logo.png' + const company = makeCompanySettings({ logo_url: url }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + // Unchanged — never worse than before (@react-pdf still fetches PNG/JPEG). + expect(resolved.logo_url).toBe(url) + }) + + it('falls back to the original URL when the fetch throws', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new Error('network down')), + ) + const url = 'https://example.test/network-error-logo.png' + const company = makeCompanySettings({ logo_url: url }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + expect(resolved.logo_url).toBe(url) + }) + + it('falls back to the original URL when the logo exceeds the size cap', async () => { + // Declared content-length over the cap is rejected before reading the body. + const fn = vi.fn().mockResolvedValue({ + ok: true, + headers: { + get: (h: string) => + h.toLowerCase() === 'content-length' ? String(6 * 1024 * 1024) : 'image/png', + }, + arrayBuffer: async () => new ArrayBuffer(0), + }) + vi.stubGlobal('fetch', fn) + const url = 'https://example.test/oversized-logo.png' + const company = makeCompanySettings({ logo_url: url }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + expect(fn).toHaveBeenCalled() + expect(resolved.logo_url).toBe(url) + }) + + it('does not fetch when no logo is configured', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const company = makeCompanySettings({ logo_url: null }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + expect(fetchMock).not.toHaveBeenCalled() + expect(resolved.logo_url).toBeNull() + }) + + it('passes through an already-embedded data: URL without fetching', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const dataUrl = `${PNG_DATA_URL_PREFIX}iVBORw0KGgo=` + const company = makeCompanySettings({ logo_url: dataUrl }) + + const { company: resolved } = await prepareInvoicePdfRender(company) + + expect(fetchMock).not.toHaveBeenCalled() + expect(resolved.logo_url).toBe(dataUrl) + }) + + it('still returns branding alongside the resolved company', async () => { + mockFetchOnce(SVG_LOGO, 'image/svg+xml') + const company = makeCompanySettings({ + logo_url: 'https://example.test/branding-logo.svg', + invoice_primary_color: '#c2410c', + }) + + const { branding } = await prepareInvoicePdfRender(company) + + expect(branding.primaryColor).toBe('#c2410c') + }) +}) diff --git a/lib/invoices/pdf-render-helpers.ts b/lib/invoices/pdf-render-helpers.ts index 8d7cc9f5..54c9f3a9 100644 --- a/lib/invoices/pdf-render-helpers.ts +++ b/lib/invoices/pdf-render-helpers.ts @@ -1,8 +1,22 @@ /** * Shared helpers for invoice PDF render call sites. * - * Wraps `brandingFromCompanySettings` so every PDF-rendering route gets a - * consistent branding object, and builds the optional Swish payment QR. + * Three responsibilities: + * 1. Build the branding object from company settings. + * 2. Resolve the company logo into a format @react-pdf/renderer can draw. + * 3. Build the optional Swish payment QR. + * + * Why the logo needs resolving (issue #772 — "Logotyp kommer inte med på + * fakturor"): @react-pdf/renderer's only decodes JPG and PNG, but the + * logo upload route and the `logos` storage bucket both accept SVG and WebP. + * When the logo is an SVG/WebP, @react-pdf fails to decode it and *silently* + * swallows the error (a console.warn inside a try/catch in its fetchImage step) + * — so the invoice renders fine but with no logo, and nothing surfaces. + * + * Fix: fetch the stored logo and re-encode it to a PNG data URL via sharp, then + * hand the template a company whose `logo_url` is that data URL. This makes the + * logo render regardless of the uploaded format and removes the render-time + * dependency on a remote fetch succeeding inside @react-pdf. */ import QRCode from 'qrcode' @@ -16,10 +30,129 @@ const log = createLogger('invoice.swish-qr') export interface InvoicePdfRenderExtras { branding: InvoiceBranding + /** + * The company settings to pass to InvoicePDF. Identical to the input except + * `logo_url` is replaced by an embedded PNG data URL when the stored logo + * could be fetched and re-encoded. Falls back to the original settings + * unchanged on any failure, so behaviour is never worse than before. + */ + company: CompanySettings } -export function prepareInvoicePdfRender(company: CompanySettings): InvoicePdfRenderExtras { - return { branding: brandingFromCompanySettings(company) } +// A company's logo is reused across every invoice render — and twice per send +// (preflight + final render), and once per invoice in recurring/batch loops — +// so cache the re-encoded result keyed by logo URL. Only successes are cached +// (with a short TTL); a transient fetch blip is retried on the next render +// rather than sticking around as a logo-less invoice. Bounded so a long-lived +// self-hosted process doesn't grow the map without limit. +const LOGO_CACHE_TTL_MS = 5 * 60 * 1000 +const LOGO_CACHE_MAX = 50 +const logoDataUrlCache = new Map() + +// The invoice draws the logo at maxWidth 150pt / maxHeight 40pt (~200px at +// print resolution), so 600px keeps it crisp while bounding the embedded +// base64 payload. +const LOGO_MAX_PX = 600 + +// Bound the logo fetch so a slow or oversized response can't hang or balloon an +// invoice render. logo_url is currently always a Supabase `logos`-bucket public +// URL (set only by the upload route), so SSRF is not reachable today — these +// caps are defense-in-depth for that invariant plus plain robustness. +const LOGO_FETCH_TIMEOUT_MS = 5_000 +const LOGO_MAX_BYTES = 5 * 1024 * 1024 // 5 MB — generous for a logo, bounds memory + +// Coalesce concurrent renders of the same logo (preflight + final on a send, and +// every invoice in a recurring/batch loop) onto one in-flight fetch+encode +// instead of each doing the full round-trip before the first result is cached. +const logoInflight = new Map>() + +/** + * Fetch a stored logo and re-encode it to a PNG data URL. Returns null on any + * failure (network error, timeout, oversized payload, unreadable image, sharp + * unavailable) — the caller then keeps the original URL, which @react-pdf can + * still fetch directly for PNG/JPEG logos. Concurrent calls for the same URL + * share a single in-flight request. + */ +async function resolveLogoDataUrl(logoUrl: string): Promise { + // Already embedded — nothing to fetch or convert. + if (logoUrl.startsWith('data:')) return logoUrl + + const cached = logoDataUrlCache.get(logoUrl) + if (cached && Date.now() - cached.at < LOGO_CACHE_TTL_MS) return cached.dataUrl + + const inflight = logoInflight.get(logoUrl) + if (inflight) return inflight + + const work = encodeLogo(logoUrl) + logoInflight.set(logoUrl, work) + try { + return await work + } finally { + // Only successes are cached (in encodeLogo); dropping the in-flight entry + // here lets a transient failure be retried on the next render. + logoInflight.delete(logoUrl) + } +} + +async function encodeLogo(logoUrl: string): Promise { + try { + const res = await fetch(logoUrl, { signal: AbortSignal.timeout(LOGO_FETCH_TIMEOUT_MS) }) + if (!res.ok) return null + + // Reject oversized payloads up front when the server declares a length, and + // again after reading in case the header lied or was absent. + const declared = Number(res.headers.get('content-length') ?? '') + if (Number.isFinite(declared) && declared > LOGO_MAX_BYTES) return null + const input = Buffer.from(await res.arrayBuffer()) + if (input.byteLength > LOGO_MAX_BYTES) return null + + // SVGs must be rasterized at a higher density or sharp renders them at + // their intrinsic (often tiny) pixel size and the result looks blurry. + const contentType = res.headers.get('content-type') ?? '' + const isSvg = + /svg/i.test(contentType) || + input.subarray(0, 256).toString('utf8').trimStart().startsWith('<') + + // Lazy, isolated import: if sharp ever fails to load in a given runtime we + // degrade to the original URL instead of breaking invoice sending entirely. + const { default: sharp } = await import('sharp') + const png = await sharp(input, isSvg ? { density: 288 } : {}) + .resize({ + width: LOGO_MAX_PX, + height: LOGO_MAX_PX, + fit: 'inside', + withoutEnlargement: true, + }) + .png() + .toBuffer() + + const dataUrl = `data:image/png;base64,${png.toString('base64')}` + + // Refresh insertion order so eviction is LRU-ish, then bound the cache. + logoDataUrlCache.delete(logoUrl) + if (logoDataUrlCache.size >= LOGO_CACHE_MAX) { + const oldest = logoDataUrlCache.keys().next().value + if (oldest !== undefined) logoDataUrlCache.delete(oldest) + } + logoDataUrlCache.set(logoUrl, { dataUrl, at: Date.now() }) + return dataUrl + } catch { + return null + } +} + +export async function prepareInvoicePdfRender( + company: CompanySettings, +): Promise { + const branding = brandingFromCompanySettings(company) + if (!company.logo_url) return { branding, company } + + const dataUrl = await resolveLogoDataUrl(company.logo_url) + const resolved = + dataUrl && dataUrl !== company.logo_url + ? { ...company, logo_url: dataUrl } + : company + return { branding, company: resolved } } /** diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index 4027e29b..86871b12 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -384,14 +384,14 @@ async function sendInvoiceFromSchedule( // Render PDF with status overridden to 'sent' so the customer doesn't // receive a "UTKAST" stamp. const renderableInvoice = { ...invoice, status: 'sent' as const } - const { branding } = prepareInvoicePdfRender(company) + const { branding, company: renderCompany } = await prepareInvoicePdfRender(company) const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, customer: invoice.customer, items, - company, + company: renderCompany, branding, swishQrDataUrl, }), diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 60f03842..892f9eef 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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,