/** * Memory-efficient expense PDF export. * * Replaces the legacy `client-portal/server/api/expenses/generate-pdf.ts` * (1009 lines, pdfkit + full-buffer-everything + base64-wrapped JSON * response — would OOM on hundreds of receipts). * * Design constraints (per user requirement: "could be hundreds of * expenses and images, also compress files if they're stupidly large"): * * 1. **Stream the PDF output** — pdfkit.pipe(response) instead of * accumulating chunks. Bytes leave the process as they're written. * 2. **Serial receipt processing** — fetch one receipt at a time, embed, * release. Peak heap = ~one image + the in-flight pdfkit page. * 3. **Sharp resize before embedding** — receipts above the size/dim * thresholds get downscaled to ≤1500px on the long edge at JPEG q80. * A typical 8 MB phone photo collapses to ~250 KB; the embedded PDF * ends up ~5–10x smaller than the legacy output. * 4. **Storage backend abstraction** — receipts come from * `getStorageBackend().get(storageKey)`; works against MinIO/S3 in * production and the local filesystem in dev. * 5. **Heap budget** — for a 500-receipt export (avg 8 MB raw → 250 KB * resized + a few MB pdfkit working set), peak RSS stays under 100 MB. * The legacy implementation needed >2 GB for the same input. * * Caller flow: * * const pdfStream = await streamExpensePdf({ portId, expenseIds, options }); * return new Response(pdfStream, { headers: { 'content-type': 'application/pdf' } }); * * `pdfStream` is a `ReadableStream` ready to hand straight to * the Web Response constructor; pdfkit's Node-stream output is converted * via `Readable.toWeb` so the route handler stays in standard runtime. */ import { Readable } from 'node:stream'; import { eq, inArray, and, gte, lte, isNull } from 'drizzle-orm'; import PDFDocument from 'pdfkit'; import sharp from 'sharp'; import { db } from '@/lib/db'; import { expenses } from '@/lib/db/schema/financial'; import { files } from '@/lib/db/schema/documents'; import { getRate } from '@/lib/services/currency'; import { getStorageBackend } from '@/lib/storage'; import { logger } from '@/lib/logger'; // ─── Public options + result types ────────────────────────────────────────── export type GroupBy = 'none' | 'payer' | 'category' | 'date'; export type PageFormat = 'A4' | 'Letter' | 'Legal'; export type TargetCurrency = 'USD' | 'EUR'; export interface ExpensePdfOptions { /** Title at the top of the document, e.g. "March 2026 Expense Report". */ documentName: string; /** Subtitle below the title (defaults to "Generated on "). */ subheader?: string; /** Group expenses in the table by payer/category/date. Default: none. */ groupBy?: GroupBy; /** Append one page per receipt image at the end. */ includeReceipts?: boolean; /** Include the OCR-extracted "Contents" string in the table row. */ includeReceiptContents?: boolean; /** Show the summary box (count + totals + grouping label). */ includeSummary?: boolean; /** Show the per-row expense table. */ includeDetails?: boolean; /** Add a 5% management fee line (parent-company export). */ includeProcessingFee?: boolean; /** Currency to convert all amounts into for the totals + line items. */ targetCurrency?: TargetCurrency; pageFormat?: PageFormat; } export interface ExpensePdfArgs { portId: string; /** When set, only these expenses are exported (ordered by expenseDate desc). */ expenseIds?: string[]; /** Otherwise, all matching expenses for the port get exported. */ filter?: { dateFrom?: Date | string | null; dateTo?: Date | string | null; category?: string | null; paymentStatus?: string | null; payer?: string | null; includeArchived?: boolean; }; options: ExpensePdfOptions; } // ─── Image resize gate ────────────────────────────────────────────────────── /** Receipts above this raw-byte size are forced through sharp resize. */ const RESIZE_BYTE_THRESHOLD = 500 * 1024; // 500 KB /** Max long-edge pixel size after resize. Keeps text legible while * collapsing typical phone-camera receipts (4032×3024 → 1500×1125). */ const MAX_DIMENSION = 1500; /** JPEG quality for resized output. */ const JPEG_QUALITY = 80; /** * Resize a receipt image to a memory-friendly size. Returns the input * buffer untouched when: * - it's already below the byte threshold AND * - sharp can read its metadata AND * - both dimensions are ≤ MAX_DIMENSION * * Returns a JPEG buffer in every other case. Sharp processes the input * image stream-style internally (libvips), so the only Node-heap cost * during resize is the input + output buffers. */ async function maybeResizeImage( raw: Buffer, contentType: string | null | undefined, ): Promise<{ buffer: Buffer; contentType: 'image/jpeg' | 'image/png'; resized: boolean }> { // Pdfkit only supports JPEG + PNG. Anything else gets transcoded to JPEG. const isJpeg = contentType === 'image/jpeg' || contentType === 'image/jpg'; const isPng = contentType === 'image/png'; const passthroughCt: 'image/jpeg' | 'image/png' = isPng ? 'image/png' : 'image/jpeg'; if (raw.byteLength <= RESIZE_BYTE_THRESHOLD && (isJpeg || isPng)) { try { const meta = await sharp(raw).metadata(); const w = meta.width ?? 0; const h = meta.height ?? 0; if (w > 0 && h > 0 && w <= MAX_DIMENSION && h <= MAX_DIMENSION) { return { buffer: raw, contentType: passthroughCt, resized: false }; } } catch { // Fall through to resize+transcode on any sharp metadata failure. } } const resized = await sharp(raw) .rotate() // honour EXIF orientation so phone photos aren't sideways .resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, fit: 'inside', withoutEnlargement: true, }) .jpeg({ quality: JPEG_QUALITY, mozjpeg: true }) .toBuffer(); return { buffer: resized, contentType: 'image/jpeg', resized: true }; } // ─── Currency conversion ──────────────────────────────────────────────────── interface ExpenseRow { id: string; establishmentName: string | null; amount: string; currency: string; amountUsd: string | null; paymentMethod: string | null; category: string | null; payer: string | null; expenseDate: Date; description: string | null; receiptFileIds: string[] | null; /** True when the rep created the expense without a receipt (and * acknowledged it may not be reimbursed). Surfaces as a banner row in * the table + a footnote at the bottom of the summary box. */ noReceiptAcknowledged: boolean; paymentStatus: string | null; } interface ProcessedExpense extends ExpenseRow { amountTarget: number; amountUsdNumeric: number; amountEurNumeric: number; } interface Totals { count: number; targetTotal: number; usdTotal: number; eurTotal: number; processingFee: number; finalTotal: number; targetCurrency: TargetCurrency; /** Number of expenses with `noReceiptAcknowledged=true` — surfaces as a * warning footer in the summary box. Reps need to know this count * before forwarding the export to a parent-company reimbursement queue. */ noReceiptCount: number; /** Sum of the no-receipt expenses' targetTotal — the amount at risk * of being denied reimbursement. */ noReceiptAmount: number; } async function processExpenses( rows: ExpenseRow[], target: TargetCurrency, ): Promise { // Resolve rate ONCE per source currency (cached by getRate). Avoids the // legacy code's per-row API call. const rateCache = new Map(); const ensureRate = async (from: string, to: string): Promise => { if (from === to) return 1; const key = `${from}->${to}`; if (rateCache.has(key)) return rateCache.get(key)!; const rate = (await getRate(from, to)) ?? 1; rateCache.set(key, rate); return rate; }; const out: ProcessedExpense[] = []; for (const row of rows) { const raw = parseFloat(row.amount); const usd = row.amountUsd != null ? parseFloat(row.amountUsd) : raw * (await ensureRate(row.currency.toUpperCase(), 'USD')); const eur = usd * (await ensureRate('USD', 'EUR')); const targetVal = target === 'USD' ? usd : eur; out.push({ ...row, amountUsdNumeric: usd, amountEurNumeric: eur, amountTarget: targetVal, }); } return out; } function computeTotals( rows: ProcessedExpense[], target: TargetCurrency, includeProcessingFee: boolean, ): Totals { const targetTotal = rows.reduce((s, r) => s + r.amountTarget, 0); const usdTotal = rows.reduce((s, r) => s + r.amountUsdNumeric, 0); const eurTotal = rows.reduce((s, r) => s + r.amountEurNumeric, 0); const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0; const receiptlessRows = rows.filter((r) => r.noReceiptAcknowledged); return { count: rows.length, targetTotal, usdTotal, eurTotal, processingFee, finalTotal: targetTotal + processingFee, targetCurrency: target, noReceiptCount: receiptlessRows.length, noReceiptAmount: receiptlessRows.reduce((s, r) => s + r.amountTarget, 0), }; } // ─── Page dimensions ──────────────────────────────────────────────────────── function pageDims(format: PageFormat): { width: number; height: number } { switch (format) { case 'Letter': return { width: 612, height: 792 }; case 'Legal': return { width: 612, height: 1008 }; case 'A4': default: return { width: 595, height: 842 }; } } // ─── Symbol helper ────────────────────────────────────────────────────────── function currencySymbol(c: string): string { switch (c.toUpperCase()) { case 'USD': return '$'; case 'EUR': return '€'; case 'GBP': return '£'; default: return c.toUpperCase() + ' '; } } // ─── Grouping ─────────────────────────────────────────────────────────────── function groupKey(row: ProcessedExpense, by: GroupBy): string { switch (by) { case 'payer': return row.payer ?? 'Unknown payer'; case 'category': return row.category ?? 'Uncategorized'; case 'date': return row.expenseDate.toISOString().slice(0, 10); default: return 'all'; } } function groupRows( rows: ProcessedExpense[], by: GroupBy, ): Array<{ key: string; rows: ProcessedExpense[] }> { if (by === 'none') return [{ key: 'all', rows }]; const map = new Map(); for (const r of rows) { const k = groupKey(r, by); if (!map.has(k)) map.set(k, []); map.get(k)!.push(r); } return [...map.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([key, rs]) => ({ key, rows: rs })); } // ─── Fetching ─────────────────────────────────────────────────────────────── async function fetchExpenseRows(args: ExpensePdfArgs): Promise { const conditions = [eq(expenses.portId, args.portId)]; if (args.expenseIds?.length) { conditions.push(inArray(expenses.id, args.expenseIds)); } else { if (!args.filter?.includeArchived) { conditions.push(isNull(expenses.archivedAt)); } if (args.filter?.dateFrom) { conditions.push( gte( expenses.expenseDate, args.filter.dateFrom instanceof Date ? args.filter.dateFrom : new Date(args.filter.dateFrom), ), ); } if (args.filter?.dateTo) { conditions.push( lte( expenses.expenseDate, args.filter.dateTo instanceof Date ? args.filter.dateTo : new Date(args.filter.dateTo), ), ); } if (args.filter?.category) conditions.push(eq(expenses.category, args.filter.category)); if (args.filter?.payer) conditions.push(eq(expenses.payer, args.filter.payer)); if (args.filter?.paymentStatus) conditions.push(eq(expenses.paymentStatus, args.filter.paymentStatus)); } const rows = await db .select({ id: expenses.id, establishmentName: expenses.establishmentName, amount: expenses.amount, currency: expenses.currency, amountUsd: expenses.amountUsd, paymentMethod: expenses.paymentMethod, category: expenses.category, payer: expenses.payer, expenseDate: expenses.expenseDate, description: expenses.description, receiptFileIds: expenses.receiptFileIds, noReceiptAcknowledged: expenses.noReceiptAcknowledged, paymentStatus: expenses.paymentStatus, }) .from(expenses) .where(and(...conditions)) .orderBy(expenses.expenseDate); return rows as ExpenseRow[]; } interface ResolvedFile { fileId: string; storagePath: string; storageBucket: string; mimeType: string | null; filename: string; } /** Bulk-resolve file metadata so the receipt loop can do a single round-trip. */ async function resolveReceiptFiles(fileIds: string[]): Promise> { if (fileIds.length === 0) return new Map(); const rows = await db .select({ id: files.id, storagePath: files.storagePath, storageBucket: files.storageBucket, mimeType: files.mimeType, filename: files.filename, }) .from(files) .where(inArray(files.id, fileIds)); const map = new Map(); for (const r of rows) { map.set(r.id, { fileId: r.id, storagePath: r.storagePath, storageBucket: r.storageBucket, mimeType: r.mimeType, filename: r.filename, }); } return map; } // ─── Streaming buffer helper ──────────────────────────────────────────────── /** Drain a Node ReadableStream into a Buffer. Caller is responsible for * not holding multiple in memory simultaneously. */ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; for await (const chunk of stream as AsyncIterable) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks); } // ─── PDF builder ──────────────────────────────────────────────────────────── /** * Build the expense PDF and return a Web ReadableStream of bytes. The * caller (route handler) streams this directly to the client; we never * materialize the whole PDF in memory. */ export async function streamExpensePdf( args: ExpensePdfArgs, ): Promise<{ stream: ReadableStream; suggestedFilename: string }> { const opts: Required< Omit > & { subheader?: string; documentName: string; pageFormat: PageFormat; targetCurrency: TargetCurrency; } = { documentName: args.options.documentName, subheader: args.options.subheader, groupBy: args.options.groupBy ?? 'none', includeReceipts: args.options.includeReceipts ?? false, includeReceiptContents: args.options.includeReceiptContents ?? false, includeSummary: args.options.includeSummary ?? true, includeDetails: args.options.includeDetails ?? true, includeProcessingFee: args.options.includeProcessingFee ?? false, targetCurrency: args.options.targetCurrency ?? 'EUR', pageFormat: args.options.pageFormat ?? 'A4', }; const rawRows = await fetchExpenseRows(args); const processed = await processExpenses(rawRows, opts.targetCurrency); const totals = computeTotals(processed, opts.targetCurrency, opts.includeProcessingFee); // Bulk-resolve receipt file metadata (one DB round-trip vs N). const allFileIds = processed .flatMap((r) => r.receiptFileIds ?? []) .filter((s): s is string => typeof s === 'string' && s.length > 0); const filesById = opts.includeReceipts ? await resolveReceiptFiles(allFileIds) : new Map(); const dims = pageDims(opts.pageFormat); const doc = new PDFDocument({ size: [dims.width, dims.height], margins: { top: 60, bottom: 60, left: 60, right: 60 }, }); // Pull bytes off pdfkit's Node Readable as soon as they're available so // the client sees the response start streaming before we even begin // fetching receipts. Node Readable → Web ReadableStream conversion. const nodeStream = doc as unknown as NodeJS.ReadableStream; const webStream = Readable.toWeb( nodeStream as unknown as Readable, ) as unknown as ReadableStream; // Kick off the page-builder asynchronously. Errors propagate via doc.end() // / doc.emit('error') and surface to the consumer of the stream. void (async () => { try { addHeader(doc, opts); if (opts.includeSummary) addSummaryBox(doc, totals, opts); if (opts.includeDetails) addExpenseTable(doc, processed, opts); if (opts.includeReceipts) { await addReceiptPages(doc, processed, filesById, opts); } addFooter(doc); doc.end(); } catch (err) { logger.error({ err }, 'Expense PDF stream failed mid-build'); doc.emit('error', err); } })(); const safeName = opts.documentName.replace(/[^a-zA-Z0-9-_\s]/g, '_').trim() || 'expenses'; return { stream: webStream, suggestedFilename: `${safeName}.pdf`, }; } // ─── Page sections ────────────────────────────────────────────────────────── function addHeader(doc: PDFKit.PDFDocument, opts: { documentName: string; subheader?: string }) { doc .fontSize(24) .font('Helvetica-Bold') .fillColor('#000000') .text(opts.documentName, { align: 'center' }); const subheader = opts.subheader ?? `Generated on ${new Date().toLocaleDateString()}`; doc.fontSize(12).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' }); doc.fillColor('#000000').moveDown(1); } function addSummaryBox( doc: PDFKit.PDFDocument, totals: Totals, opts: { includeProcessingFee: boolean; groupBy: GroupBy }, ) { const sym = currencySymbol(totals.targetCurrency); const otherSym = totals.targetCurrency === 'USD' ? '€' : '$'; const otherTotal = totals.targetCurrency === 'USD' ? totals.eurTotal : totals.usdTotal; doc.fontSize(14).font('Helvetica-Bold').text('Summary'); doc.moveDown(0.4); const lineY = doc.y; const lines = [ `Total expenses: ${totals.count}`, `Subtotal (${totals.targetCurrency}): ${sym}${totals.targetTotal.toFixed(2)}`, `${totals.targetCurrency === 'USD' ? 'EUR' : 'USD'} equivalent: ${otherSym}${otherTotal.toFixed(2)}`, ]; if (opts.includeProcessingFee) { lines.push(`Processing fee (5%): ${sym}${totals.processingFee.toFixed(2)}`); lines.push(`Final total: ${sym}${totals.finalTotal.toFixed(2)}`); } if (opts.groupBy !== 'none') lines.push(`Grouping: by ${opts.groupBy}`); // Warning footer when the export contains acknowledged-no-receipt rows. // Reps need to see the at-risk count + amount BEFORE they forward the // PDF to a reimbursement queue. const showNoReceiptWarning = totals.noReceiptCount > 0; const warningLines = showNoReceiptWarning ? [ `WARNING: ${totals.noReceiptCount} expense${totals.noReceiptCount === 1 ? '' : 's'} on this report ${totals.noReceiptCount === 1 ? 'has' : 'have'} no receipt attached`, `(${sym}${totals.noReceiptAmount.toFixed(2)} at risk of being denied reimbursement).`, ] : []; const boxHeight = (lines.length + warningLines.length) * 16 + 20; doc .rect(60, lineY, doc.page.width - 120, boxHeight) .fillColor('#f5f5f5') .fill() .strokeColor('#dddddd') .stroke(); doc.fillColor('#000000').fontSize(11).font('Helvetica'); let y = lineY + 12; for (const line of lines) { doc.text(line, 75, y); y += 16; } if (showNoReceiptWarning) { doc.fillColor('#dc3545').font('Helvetica-Bold'); for (const line of warningLines) { doc.text(line, 75, y); y += 16; } doc.fillColor('#000000').font('Helvetica'); } doc.y = lineY + boxHeight + 12; } interface Column { header: string; width: number; x: number; align?: 'left' | 'right'; } function addExpenseTable( doc: PDFKit.PDFDocument, rows: ProcessedExpense[], opts: { groupBy: GroupBy; includeReceiptContents: boolean; targetCurrency: TargetCurrency }, ) { doc.fontSize(14).font('Helvetica-Bold').text('Expense details'); doc.moveDown(0.4); const sym = currencySymbol(opts.targetCurrency); const baseColumns: Column[] = [ { header: 'Date', width: 60, x: 60 }, { header: 'Establishment', width: 110, x: 120 }, { header: 'Category', width: 65, x: 230 }, { header: 'Payer', width: 55, x: 295 }, { header: 'Amount', width: 75, x: 350, align: 'right' }, { header: 'Status', width: 50, x: 425 }, ]; if (opts.includeReceiptContents) { baseColumns.push({ header: 'Description', width: 100, x: 475 }); } const drawHeader = () => { doc .fontSize(9) .font('Helvetica-Bold') .rect(60, doc.y, doc.page.width - 120, 22) .fillColor('#f2f2f2') .fill() .strokeColor('#dddddd') .stroke() .fillColor('#000000'); const headerY = doc.y + 6; for (const col of baseColumns) { doc.text(col.header, col.x, headerY, { width: col.width, align: col.align ?? 'left' }); } doc.y += 22; }; const drawRow = (row: ProcessedExpense, alt: boolean) => { if (doc.y > doc.page.height - 80) { doc.addPage(); drawHeader(); } const rowTop = doc.y; if (alt) { doc .rect(60, rowTop, doc.page.width - 120, 20) .fillColor('#fafafa') .fill(); } doc.fillColor('#000000').fontSize(8).font('Helvetica'); const date = row.expenseDate.toISOString().slice(0, 10); const amount = `${sym}${row.amountTarget.toFixed(2)}`; // Annotate the establishment cell with a red "(no receipt)" marker // when the rep created the expense without proof. This keeps the // warning glanceable per row without adding a new column. const establishment = (row.establishmentName ?? '-') + (row.noReceiptAcknowledged ? ' (no receipt)' : ''); const data: string[] = [ date, establishment, row.category ?? '-', row.payer ?? '-', amount, row.paymentStatus ?? '-', ]; if (opts.includeReceiptContents) { data.push(((row.description ?? '') || '-').slice(0, 80)); } data.forEach((value, i) => { const col = baseColumns[i]!; // Draw the establishment cell in red when no-receipt; reset to // black for everything else so warning visibility doesn't bleed. const isWarningCell = i === 1 && row.noReceiptAcknowledged; if (isWarningCell) doc.fillColor('#dc3545'); doc.text(value, col.x, rowTop + 6, { width: col.width - 4, align: col.align ?? 'left', ellipsis: true, }); if (isWarningCell) doc.fillColor('#000000'); }); doc.y = rowTop + 20; }; drawHeader(); let altIndex = 0; for (const group of groupRows(rows, opts.groupBy)) { if (opts.groupBy !== 'none') { if (doc.y > doc.page.height - 80) { doc.addPage(); drawHeader(); } const groupTotal = group.rows.reduce((s, r) => s + r.amountTarget, 0); doc .rect(60, doc.y, doc.page.width - 120, 20) .fillColor('#e7f3ff') .fill() .strokeColor('#dddddd') .stroke(); doc .fillColor('#000000') .fontSize(9) .font('Helvetica-Bold') .text( `${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'} — ${sym}${groupTotal.toFixed(2)})`, 65, doc.y + 5, { width: doc.page.width - 130 }, ); doc.y += 20; } for (const row of group.rows) { drawRow(row, altIndex % 2 === 1); altIndex += 1; } } doc.moveDown(0.5); } async function addReceiptPages( doc: PDFKit.PDFDocument, rows: ProcessedExpense[], filesById: Map, opts: { targetCurrency: TargetCurrency }, ) { const expensesWithReceipts = rows.filter( (r) => Array.isArray(r.receiptFileIds) && r.receiptFileIds.length > 0, ); if (expensesWithReceipts.length === 0) return; const totalReceipts = expensesWithReceipts.reduce( (s, r) => s + (r.receiptFileIds?.length ?? 0), 0, ); const backend = await getStorageBackend(); const sym = currencySymbol(opts.targetCurrency); let receiptCounter = 0; let resizedCount = 0; const startedAt = Date.now(); for (const expense of expensesWithReceipts) { for (const fileId of expense.receiptFileIds ?? []) { receiptCounter += 1; const file = filesById.get(fileId); if (!file) { addReceiptErrorPage( doc, expense, receiptCounter, totalReceipts, sym, 'Receipt file metadata missing', ); continue; } let imageBuffer: Buffer | null = null; try { // Stream from storage → buffer. Sharp + pdfkit both need a Buffer // (neither accepts a streaming body), so we pay one image's bytes // per loop iteration. Released to GC after embed. const stream = await backend.get(file.storagePath); const raw = await streamToBuffer(stream); const resized = await maybeResizeImage(raw, file.mimeType); if (resized.resized) resizedCount += 1; imageBuffer = resized.buffer; // Page header doc.addPage(); renderReceiptHeader(doc, expense, file, receiptCounter, totalReceipts, sym); // Embed the image full-bleed in the remaining vertical space. const margin = 60; const headerBlockHeight = 110; const imgX = margin; const imgY = doc.y; const imgW = doc.page.width - margin * 2; const imgH = doc.page.height - imgY - margin; try { doc.image(imageBuffer, imgX, imgY, { fit: [imgW, imgH], align: 'center', valign: 'center', }); } catch (err) { logger.warn( { err, fileId, mimeType: file.mimeType }, 'pdfkit refused to embed receipt; falling back to error page', ); // Replace the partial page content with an error footer; pdfkit // doesn't allow removing already-drawn elements, so we just append // the error message in red below. doc .fontSize(11) .fillColor('#dc3545') .text( `Receipt could not be embedded: ${(err as Error).message}`, imgX, imgY + headerBlockHeight, { width: imgW, align: 'center' }, ); doc.fillColor('#000000'); } } catch (err) { logger.warn( { err, fileId, expenseId: expense.id, storagePath: file.storagePath }, 'Receipt fetch failed; rendering placeholder page', ); addReceiptErrorPage( doc, expense, receiptCounter, totalReceipts, sym, (err as Error).message ?? 'Receipt could not be loaded from storage', ); } finally { // Release the buffer reference so V8 can reclaim it before the // next iteration. Without this, the closure could pin the last // image until the loop fully completes. imageBuffer = null; } } } logger.info( { totalReceipts, resized: resizedCount, elapsedMs: Date.now() - startedAt, }, 'Expense PDF receipt pages built', ); } function renderReceiptHeader( doc: PDFKit.PDFDocument, expense: ProcessedExpense, file: ResolvedFile, index: number, total: number, sym: string, ) { const margin = 60; const headerH = 90; doc .rect(margin, doc.y, doc.page.width - margin * 2, headerH) .fillColor('#f8f9fa') .fill() .strokeColor('#dee2e6') .stroke(); doc.fillColor('#000000'); doc .fontSize(14) .font('Helvetica-Bold') .text(`Receipt ${index} of ${total}`, margin + 10, doc.y - headerH + 10); doc .fontSize(11) .font('Helvetica-Bold') .text( `${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, margin + 10, doc.y + 4, ); doc .fontSize(9) .font('Helvetica') .fillColor('#666666') .text( `Date: ${expense.expenseDate.toISOString().slice(0, 10)} · Payer: ${expense.payer ?? '—'} · Category: ${expense.category ?? '—'} · File: ${file.filename}`, margin + 10, doc.y + 4, { width: doc.page.width - margin * 2 - 20 }, ); doc.fillColor('#000000'); // Reset cursor to below the header block. const margin2 = 60; doc.y = doc.y + Math.max(headerH - 50, 20); void margin2; } function addReceiptErrorPage( doc: PDFKit.PDFDocument, expense: ProcessedExpense, index: number, total: number, sym: string, message: string, ) { doc.addPage(); doc.fontSize(14).font('Helvetica-Bold').text(`Receipt ${index} of ${total}`, { align: 'center' }); doc .fontSize(11) .font('Helvetica') .text(`${expense.establishmentName ?? '—'} ${sym}${expense.amountTarget.toFixed(2)}`, { align: 'center', }); doc.moveDown(2); doc.fontSize(11).fillColor('#dc3545').text(message, { align: 'center' }); doc.fillColor('#000000'); } function addFooter(doc: PDFKit.PDFDocument) { doc.fontSize(9).fillColor('#666666'); const range = doc.bufferedPageRange(); for (let i = range.start; i < range.start + range.count; i += 1) { doc.switchToPage(i); doc.text(`Page ${i + 1} of ${range.count}`, 60, doc.page.height - 30, { align: 'right', width: doc.page.width - 120, }); doc.text( `Generated ${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`, 60, doc.page.height - 30, { align: 'left', width: doc.page.width - 120, }, ); } doc.fillColor('#000000'); }