Files
pn-new-crm/src/lib/services/expense-pdf.service.ts
Matt f3aae61ad8 feat(utils): formatDate helper + sample sweep through PDF + template paths
Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat
(no new dep — built into Node 18+ + every supported browser). Replaces 96
ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the
codebase.

src/lib/utils/format-date.ts (new):
  formatDate(value, preset?, options?)         — primary helper
  formatDateRange(start, end, options?)        — collapsed range strings
  formatRelative(value, options?)              — "3 hours ago" / "in 2 days"

  Presets (named so callers don't memorize Intl options shape):
    date.short        12 May
    date.medium       12 May 2026
    date.long         Monday, 12 May 2026
    date.iso          2026-05-12 (TZ-aware ISO date, no time)
    datetime.short    12 May 14:30
    datetime.medium   12 May 2026 14:30
    datetime.long     Monday, 12 May 2026 at 14:30 UTC
    datetime.iso      2026-05-12T14:30:00.000Z
    time              14:30

  Defensive defaults:
    - null/undefined/Invalid Date → '—' (overridable via { fallback })
    - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift)
    - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name)

Sample sweep (3 sites — proves the pattern; remaining 93 sites can be
migrated opportunistically when files are touched):
  src/lib/services/expense-pdf.service.ts:608  default subheader
  src/lib/services/document-templates.ts:364   {{interest.dateFirstContact}}
  src/lib/services/document-templates.ts:374-378  {{interest.date*Signed}}

The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule:
"replace as you touch the file" — gives compounding cleanup without
a single risky 90-file commit.

tests/unit/format-date.test.ts (new) — 17 tests:
  - fallback handling (null/undefined/invalid/explicit)
  - date.iso correctness in UTC + non-UTC timezones
  - datetime.iso = full ISO string
  - en-GB locale-formatted output
  - timezone respect across NY/UTC
  - time-only preset
  - Date/string/epoch ms inputs all accepted
  - formatDateRange same-year collapse, different-year keep, missing ends
  - formatRelative: just-now / minutes / hours / days / future / invalid

1315/1315 vitest green (+17 new from format-date.test.ts).

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

1046 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 { formatDate } from '@/lib/utils/format-date';
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 ${formatDate(new Date(), 'date.medium')}`;
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');
}