* 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:
co-authored by
Claude Opus 4.8
parent
739f18fd1c
commit
10a0b1d8dd
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Regression tests for issue #772 — "Logotyp kommer inte med på fakturor".
|
||||
*
|
||||
* Root cause: @react-pdf/renderer's <Image> 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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40">' +
|
||||
'<rect width="120" height="40" fill="#1a1a1a"/>' +
|
||||
'<text x="8" y="26" fill="#fff" font-size="18">ACME</text></svg>',
|
||||
)
|
||||
|
||||
/** 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')
|
||||
})
|
||||
})
|
||||
@@ -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 <Image> 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<string, { dataUrl: string; at: number }>()
|
||||
|
||||
// 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<string, Promise<string | null>>()
|
||||
|
||||
/**
|
||||
* 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<string | null> {
|
||||
// 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<string | null> {
|
||||
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<InvoicePdfRenderExtras> {
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user