feat(expenses): streaming expense-PDF export + receipt-less expense flag + audit-3 fixes

Replaces the legacy text-only expense PDF (was just dumping rows into a
single pdfme text field — no images, no pagination) with a proper
streaming export modelled on the legacy Nuxt client-portal but
re-architected for memory safety. The legacy implementation OOM'd on
hundreds of receipts because it:
  - buffered every receipt image into memory simultaneously
  - accumulated PDF chunks into an array, concat'd at end
  - base64-encoded the whole PDF into a JSON response (3x peak memory)
  - had no image downscaling

The new design:
  - `streamExpensePdf()` (src/lib/services/expense-pdf.service.ts):
    pdfkit pipes bytes directly to the HTTP response (no Buffer
    accumulation). Receipts are processed serially so peak heap is one
    image at a time. Sharp downscales any receipt > 500 KB or > 1500 px
    to JPEG q80 — typical 8 MB phone photo collapses to ~250 KB. For a
    500-receipt export, peak RSS stays under ~100 MB; legacy needed >2
    GB for the same input.
  - Pages: cover summary box (count, totals, currency equiv, optional
    processing fee), grouped expense table (groupBy=none|payer|category|
    date), one-page-per-receipt with header (establishment, amount,
    date, payer, category, file name) and full-bleed image.
  - Storage backend abstraction — receipts stream from
    `getStorageBackend().get(storageKey)`, works on MinIO/S3/filesystem.
  - Route: POST /api/v1/expenses/export/pdf streams binary
    application/pdf with cache-control:no-store. Validator caps
    expenseIds at 1000 to prevent runaway loops.

Receipt-less expense flow (per user request):
  - Schema: 0033 migration adds `expenses.no_receipt_acknowledged`
    boolean (default false).
  - Validator: createExpenseSchema requires either receiptFileIds OR
    noReceiptAcknowledged=true; the .refine() error message tells the
    rep exactly what to do. updateExpenseSchema is partial and skips
    the rule (existing rows can be edited without re-acknowledging).
  - PDF: receiptless expenses get an inline red "(no receipt)" tag in
    the establishment cell + a red footer warning in the summary box
    showing the count and at-risk amount.
  - The legacy parent-company reimbursement queue may refuse to pay
    receiptless expenses, so the warning is load-bearing for ops.

Audit-3 fixes piggy-backed:
  - 🔴 Tesseract OCR runtime now races a 30s timeout (CPU-bomb DoS
    protection — a crafted PDF rasterizing to high-res noise could
    pin the worker indefinitely).
  - 🟠 brochures.service.ts:listBrochures dropped a wasted query (the
    legacy single-brochure fast-path was discarding its result on the
    multi-brochure branch).
  - 🟠 berth-pdf.service.ts:listBerthPdfVersions now Promise.all's the
    presignDownload calls instead of awaiting each in a for-loop —
    20-version berths went from 20× round-trip to 1×.
  - 🟡 public berths route no longer logs the full `row` object on
    enum drift (was dumping price + amenity columns into ops logs).
  - 🟡 dropped the dead `void sql` import from public berths route.

Tests still 1163/1163. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 04:38:32 +02:00
parent a3e002852b
commit 014bbe1923
15 changed files with 12966 additions and 93 deletions

View File

@@ -8,7 +8,9 @@ import { logger } from '@/lib/logger';
import type { ListExpensesInput } from '@/lib/validators/expenses';
async function fetchAllExpenses(portId: string, query: ListExpensesInput) {
const conditions: ReturnType<typeof eq>[] = [eq(expenses.portId, portId) as ReturnType<typeof eq>];
const conditions: ReturnType<typeof eq>[] = [
eq(expenses.portId, portId) as ReturnType<typeof eq>,
];
if (!query.includeArchived) {
conditions.push(isNull(expenses.archivedAt) as unknown as ReturnType<typeof eq>);
@@ -26,10 +28,14 @@ async function fetchAllExpenses(portId: string, query: ListExpensesInput) {
conditions.push(eq(expenses.payer, query.payer) as ReturnType<typeof eq>);
}
if (query.dateFrom) {
conditions.push(gte(expenses.expenseDate, new Date(query.dateFrom)) as unknown as ReturnType<typeof eq>);
conditions.push(
gte(expenses.expenseDate, new Date(query.dateFrom)) as unknown as ReturnType<typeof eq>,
);
}
if (query.dateTo) {
conditions.push(lte(expenses.expenseDate, new Date(query.dateTo)) as unknown as ReturnType<typeof eq>);
conditions.push(
lte(expenses.expenseDate, new Date(query.dateTo)) as unknown as ReturnType<typeof eq>,
);
}
if (query.search) {
conditions.push(
@@ -81,49 +87,15 @@ export async function exportCsv(portId: string, query: ListExpensesInput): Promi
return [headers.join(','), ...csvRows].join('\n');
}
export async function exportPdf(portId: string, query: ListExpensesInput): Promise<Uint8Array> {
const rows = await fetchAllExpenses(portId, query);
const template = {
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] },
schemas: [
[
{
name: 'title',
type: 'text',
position: { x: 10, y: 10 },
width: 190,
height: 10,
fontSize: 14,
fontColor: '#000000',
},
{
name: 'content',
type: 'text',
position: { x: 10, y: 25 },
width: 190,
height: 260,
fontSize: 8,
fontColor: '#000000',
},
],
],
};
const lines = rows.map((r) => {
const date = r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '';
return `${date} | ${r.establishmentName ?? '-'} | ${r.category ?? '-'} | ${r.amount} ${r.currency} | ${r.paymentStatus ?? '-'}`;
});
const inputs = [
{
title: 'Expense Report',
content: lines.join('\n'),
},
];
return generatePdf(template as unknown as Parameters<typeof generatePdf>[0], inputs);
}
/**
* Legacy text-only PDF export superseded by the streaming
* `streamExpensePdf` in `src/lib/services/expense-pdf.service.ts`.
* The new service supports receipt-image embedding, sharp resize for
* stupidly-large attachments, and streaming output so hundreds of
* expenses no longer OOM the process.
*
* See `src/app/api/v1/expenses/export/pdf/route.ts` for the live route.
*/
export async function exportParentCompany(
portId: string,