Files
pn-new-crm/src/lib/services/expense-pdf.service.ts
Matt b7e010ff80 feat(expense-export): parent-company react-pdf + pdfkit brand header
Phase 1 / commit 10 of 14 — migrates the pdfme-based parent-company
expense export to react-pdf and adds a shared brand header to the
pdfkit-based streaming expense PDF so both surfaces match the rest of
the internal-only PDF family.

parent-company-expense.tsx:
  Summary KV grid (entry count, subtotal, fee, total) + entries table
  with right-aligned EUR amounts and a totals row. Footnote rendered
  when the EUR rate lookup falls through to the 1:1 USD:EUR fallback.

expense-export.tsx (renamed .ts -> .tsx):
  - exportParentCompany now renders the react-pdf template via
    resolvePortLogo() + renderPdf()
  - dropped the inline pdfme template object (was the last pdfme caller
    in this file)
  - return type widened from Uint8Array to Buffer; caller already wraps
    in Buffer.from() so no API change downstream

expense-pdf.service.ts (the pdfkit streaming engine — unchanged):
  - addHeader() now draws a dark slate band matching the brand-kit
    header band, with the port logo letterboxed on the left and the
    document title right-aligned. Falls back to text port-name if the
    logo image is missing or can't be decoded by pdfkit
  - port + logo resolved once per export via Promise.all
  - subheader stays beneath the band in muted grey, same as before
  - streaming behavior + receipt embedding + sharp compression
    untouched — the only change is the visual treatment of the header

Old pdfme inline template deleted along with the generatePdf import.
After this commit, the only remaining pdfme imports are in:
  invoice-template.ts, tiptap-to-pdfme.ts, eoi-standard-inapp.ts, and
  document-templates.ts (lines 516-522). All four are removed in
  commits 11-12.

1319/1319 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:01:45 +02:00

1045 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 ~510x 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<Uint8Array>` 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, desc } 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 { ports } from '@/lib/db/schema/ports';
import { getRate } from '@/lib/services/currency';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
// ─── Public options + result types ──────────────────────────────────────────
export type GroupBy = 'none' | 'payer' | 'category' | 'date' | 'trip';
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 <today>"). */
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;
tripLabel?: string | null;
includeArchived?: boolean;
};
options: ExpensePdfOptions;
/**
* Caller's abort signal. When the client disconnects mid-stream we stop
* pulling receipts off the storage backend rather than burning CPU/IO on
* an export nobody's reading. Without this, a 1000-receipt export aborted
* at byte 0 keeps the process busy for minutes.
*/
signal?: AbortSignal;
}
// ─── 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;
/** Free-text trip / event label — drives `groupBy=trip` sectioning. */
tripLabel: string | null;
}
interface ProcessedExpense extends ExpenseRow {
amountTarget: number;
amountUsdNumeric: number;
amountEurNumeric: number;
/** True when ANY rate lookup for this row fell back to 1:1 (e.g. the
* exchange-rate cache was cold and the upstream API returned null).
* Surfaced via an asterisk in the table + a footnote in the summary. */
rateUnavailable: boolean;
}
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;
/** Number of rows whose conversion fell back to 1:1 — surfaces as an
* amber footer so reps know the totals are approximate. Audit caught
* the silent 1:1 fallback; users were getting EUR-labelled USD totals. */
rateUnavailableCount: number;
}
async function processExpenses(
rows: ExpenseRow[],
target: TargetCurrency,
): Promise<ProcessedExpense[]> {
// Resolve rate ONCE per source currency (cached by getRate). Avoids the
// legacy code's per-row API call. We also track *which* lookups failed
// (returned null upstream) so the PDF can surface a warning rather than
// silently treating EUR as USD.
const rateCache = new Map<string, { rate: number; ok: boolean }>();
const ensureRate = async (from: string, to: string): Promise<{ rate: number; ok: boolean }> => {
if (from === to) return { rate: 1, ok: true };
const key = `${from}->${to}`;
if (rateCache.has(key)) return rateCache.get(key)!;
const fetched = await getRate(from, to);
const entry = fetched != null ? { rate: fetched, ok: true } : { rate: 1, ok: false };
rateCache.set(key, entry);
if (!entry.ok) {
logger.warn({ from, to }, 'Expense PDF: exchange rate unavailable, falling back to 1:1');
}
return entry;
};
const out: ProcessedExpense[] = [];
for (const row of rows) {
const raw = parseFloat(row.amount);
let rateUnavailable = false;
let usd: number;
if (row.amountUsd != null) {
usd = parseFloat(row.amountUsd);
} else if (row.currency.toUpperCase() === 'USD') {
usd = raw;
} else {
const { rate, ok } = await ensureRate(row.currency.toUpperCase(), 'USD');
usd = raw * rate;
if (!ok) rateUnavailable = true;
}
// Skip the USD->EUR chain when the source already matches the target —
// every redundant rate lookup adds rounding noise on top of the network
// round-trip. EUR-source + EUR-target should land back exactly at the
// input amount, not raw * USD-rate * USD-rate-inverse.
let eur: number;
if (row.currency.toUpperCase() === 'EUR') {
eur = raw;
} else {
const { rate, ok } = await ensureRate('USD', 'EUR');
eur = usd * rate;
if (!ok) rateUnavailable = true;
}
const targetVal = target === 'USD' ? usd : eur;
out.push({
...row,
amountUsdNumeric: usd,
amountEurNumeric: eur,
amountTarget: targetVal,
rateUnavailable,
});
}
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);
const rateUnavailableCount = rows.reduce((n, r) => n + (r.rateUnavailable ? 1 : 0), 0);
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),
rateUnavailableCount,
};
}
// ─── 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 };
}
}
// ─── Grouping ───────────────────────────────────────────────────────────────
function groupKey(row: ProcessedExpense, by: GroupBy): string {
switch (by) {
case 'payer':
return row.payer ?? 'Unknown payer';
case 'category':
return row.category ?? 'Uncategorized';
case 'trip':
// Untagged rows go under "(no trip)" so they're still in the
// export, just collected at the bottom.
return row.tripLabel ?? '(no trip)';
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<string, ProcessedExpense[]>();
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<ExpenseRow[]> {
const conditions = [eq(expenses.portId, args.portId)];
// Soft-delete filter applies regardless of which path produced the
// expense list. The audit caught a regression where an `expenseIds`
// selection would happily export archived rows because the
// `isNull(archivedAt)` predicate sat inside the `else` branch — that
// violates the soft-delete contract used everywhere else.
if (!args.filter?.includeArchived) {
conditions.push(isNull(expenses.archivedAt));
}
if (args.expenseIds?.length) {
conditions.push(inArray(expenses.id, args.expenseIds));
} else {
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));
if (args.filter?.tripLabel) conditions.push(eq(expenses.tripLabel, args.filter.tripLabel));
}
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,
tripLabel: expenses.tripLabel,
paymentStatus: expenses.paymentStatus,
})
.from(expenses)
.where(and(...conditions))
.orderBy(desc(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<Map<string, ResolvedFile>> {
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<string, ResolvedFile>();
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<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Buffer | string>) {
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<Uint8Array>; suggestedFilename: string }> {
const opts: Required<
Omit<ExpensePdfOptions, 'subheader' | 'documentName' | 'pageFormat' | 'targetCurrency'>
> & {
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);
// Brand assets for the header band — shared with the react-pdf surfaces.
const [port, logo] = await Promise.all([
db.query.ports.findFirst({ where: eq(ports.id, args.portId) }),
resolvePortLogo(args.portId),
]);
// 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<string, ResolvedFile>();
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<Uint8Array>;
// 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, portName: port?.name, logoBuffer: logo.buffer });
if (opts.includeSummary) addSummaryBox(doc, totals, opts);
if (opts.includeDetails) addExpenseTable(doc, processed, opts);
if (opts.includeReceipts) {
await addReceiptPages(doc, processed, filesById, {
targetCurrency: opts.targetCurrency,
signal: args.signal,
});
}
addFooter(doc);
doc.end();
} catch (err) {
logger.error({ err }, 'Expense PDF stream failed mid-build');
doc.emit('error', err);
}
})();
// `\s` includes CR/LF; using it lets a malicious documentName forge
// additional response headers via Content-Disposition. Restrict to
// word/dot/dash/space (single-line space only — \s would let \n through).
const safeName = opts.documentName.replace(/[^\w. \-]+/g, '_').trim() || 'expenses';
return {
stream: webStream,
suggestedFilename: `${safeName}.pdf`,
};
}
// ─── Page sections ──────────────────────────────────────────────────────────
function addHeader(
doc: PDFKit.PDFDocument,
opts: {
documentName: string;
subheader?: string;
portName?: string;
logoBuffer?: Buffer | null;
},
) {
// Brand band — shared visual language with the react-pdf surfaces.
// Dark slate matches PDF_TOKENS.colors.headerBand.
const pageWidth = doc.page.width;
const bandHeight = 56;
const bandPaddingX = 24;
const startY = doc.page.margins.top - 30;
doc.save();
doc.rect(0, 0, pageWidth, bandHeight).fill('#0f172a');
doc.fillColor('#ffffff');
if (opts.logoBuffer) {
try {
doc.image(opts.logoBuffer, bandPaddingX, (bandHeight - 32) / 2, {
fit: [120, 32],
});
} catch {
// Fall through to the text-name fallback if pdfkit can't decode.
if (opts.portName) {
doc
.fontSize(14)
.font('Helvetica-Bold')
.text(opts.portName, bandPaddingX, (bandHeight - 14) / 2, { lineBreak: false });
}
}
} else if (opts.portName) {
doc
.fontSize(14)
.font('Helvetica-Bold')
.text(opts.portName, bandPaddingX, (bandHeight - 14) / 2, { lineBreak: false });
}
doc
.fontSize(13)
.font('Helvetica-Bold')
.text(opts.documentName, pageWidth / 2, (bandHeight - 13) / 2, {
align: 'right',
width: pageWidth - pageWidth / 2 - bandPaddingX,
lineBreak: false,
});
doc.restore();
// Move past the band and render the subheader below in black on white.
doc.y = bandHeight + 12;
const subheader = opts.subheader ?? `Generated on ${new Date().toLocaleDateString()}`;
doc.fontSize(11).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
doc.fillColor('#000000').moveDown(0.8);
// Suppress unused-warning on startY (kept as a documentation anchor).
void startY;
}
function addSummaryBox(
doc: PDFKit.PDFDocument,
totals: Totals,
opts: { includeProcessingFee: boolean; groupBy: GroupBy },
) {
const otherCurrency = totals.targetCurrency === 'USD' ? 'EUR' : '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}): ${formatCurrency(totals.targetTotal, totals.targetCurrency)}`,
`${otherCurrency} equivalent: ${formatCurrency(otherTotal, otherCurrency)}`,
];
if (opts.includeProcessingFee) {
lines.push(
`Processing fee (5%): ${formatCurrency(totals.processingFee, totals.targetCurrency)}`,
);
lines.push(`Final total: ${formatCurrency(totals.finalTotal, totals.targetCurrency)}`);
}
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`,
`(${formatCurrency(totals.noReceiptAmount, totals.targetCurrency)} at risk of being denied reimbursement).`,
]
: [];
// Second warning band: any row whose currency conversion fell back to
// 1:1 because the upstream rate was unavailable. Without this surface,
// an EUR-source row would appear as `targetCurrency=EUR, amount=USD`
// and reps would never know the totals are wrong.
const showRateWarning = totals.rateUnavailableCount > 0;
const rateWarningLines = showRateWarning
? [
`Note: ${totals.rateUnavailableCount} expense${totals.rateUnavailableCount === 1 ? '' : 's'} could not be converted (rate unavailable);`,
`the displayed amount${totals.rateUnavailableCount === 1 ? ' is' : 's are'} approximate (1:1 fallback).`,
]
: [];
const boxHeight = (lines.length + warningLines.length + rateWarningLines.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');
}
if (showRateWarning) {
doc.fillColor('#92400e').font('Helvetica-Oblique');
for (const line of rateWarningLines) {
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 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 = formatCurrency(row.amountTarget, opts.targetCurrency);
// 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'}${formatCurrency(groupTotal, opts.targetCurrency)})`,
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<string, ResolvedFile>,
opts: { targetCurrency: TargetCurrency; signal?: AbortSignal },
) {
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();
let receiptCounter = 0;
let resizedCount = 0;
const startedAt = Date.now();
for (const expense of expensesWithReceipts) {
for (const fileId of expense.receiptFileIds ?? []) {
// Bail out the moment the client disconnects. Without this, an
// export aborted on the wire would keep grinding through the
// remaining receipts and only stop when the doc.end() write
// failed — minutes later for a 1000-row export.
if (opts.signal?.aborted) {
logger.info(
{ receiptCounter, totalReceipts },
'Expense PDF stream aborted by client; halting receipt loop',
);
return;
}
receiptCounter += 1;
const file = filesById.get(fileId);
if (!file) {
addReceiptErrorPage(
doc,
expense,
receiptCounter,
totalReceipts,
opts.targetCurrency,
'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, opts.targetCurrency);
// 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,
opts.targetCurrency,
(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,
currency: string,
) {
const margin = 60;
const headerH = 90;
// Capture the header's top edge BEFORE drawing — every subsequent text
// call below uses pdfkit's auto-flow which advances `doc.y`. Using
// `doc.y - headerH + 10` after the rect+stroke block computes against
// the post-rect position and only happens to work because pdfkit's
// text-after-rect hasn't moved y yet. On the first receipt page after
// a soft page break that assumption breaks and the header misaligns.
const baseY = doc.y;
doc
.rect(margin, baseY, 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, baseY + 10);
doc
.fontSize(11)
.font('Helvetica-Bold')
.text(
`${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
margin + 10,
baseY + 36,
);
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,
baseY + 56,
{ width: doc.page.width - margin * 2 - 20 },
);
doc.fillColor('#000000');
// Reset cursor to below the header block, anchored to the captured
// baseline so it's independent of however many auto-flowed text runs
// occurred above.
doc.y = baseY + headerH + 8;
}
function addReceiptErrorPage(
doc: PDFKit.PDFDocument,
expense: ProcessedExpense,
index: number,
total: number,
currency: 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 ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
{ 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');
}