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

@@ -52,6 +52,7 @@
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/pdfkit": "^0.17.6",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"bullmq": "^5.25.0", "bullmq": "^5.25.0",
@@ -73,6 +74,7 @@
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"openai": "^6.27.0", "openai": "^6.27.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.18.0",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"postgres": "^3.4.0", "postgres": "^3.4.0",
@@ -81,6 +83,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"sharp": "^0.34.5",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"sonner": "^1.7.0", "sonner": "^1.7.0",

357
pnpm-lock.yaml generated
View File

@@ -101,6 +101,9 @@ importers:
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/pdfkit':
specifier: ^0.17.6
version: 0.17.6
archiver: archiver:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
@@ -164,6 +167,9 @@ importers:
pdf-lib: pdf-lib:
specifier: ^1.17.1 specifier: ^1.17.1
version: 1.17.1 version: 1.17.1
pdfkit:
specifier: ^0.18.0
version: 0.18.0
pino: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0
@@ -188,6 +194,9 @@ importers:
recharts: recharts:
specifier: ^3.8.0 specifier: ^3.8.0
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1) version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1)
sharp:
specifier: ^0.34.5
version: 0.34.5
socket.io: socket.io:
specifier: ^4.8.0 specifier: ^4.8.0
version: 4.8.3 version: 4.8.3
@@ -1153,64 +1162,138 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.33.5': '@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5': '@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4': '@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4': '@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4': '@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5': '@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4': '@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4': '@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4': '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4': '@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5': '@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1218,6 +1301,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5': '@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1225,6 +1315,27 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5': '@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1232,6 +1343,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5': '@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1239,6 +1357,13 @@ packages:
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5': '@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1246,6 +1371,13 @@ packages:
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5': '@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1253,23 +1385,53 @@ packages:
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5': '@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32] cpu: [wasm32]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.33.5': '@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5': '@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@ioredis/commands@1.5.0': '@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
@@ -1390,10 +1552,18 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
'@noble/ciphers@2.1.1': '@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.0.1': '@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
@@ -2328,6 +2498,9 @@ packages:
'@types/nodemailer@6.4.23': '@types/nodemailer@6.4.23':
resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==} resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==}
'@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@@ -2776,6 +2949,10 @@ packages:
bare-url@2.4.2: bare-url@2.4.2:
resolution: {integrity: sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==} resolution: {integrity: sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -2893,6 +3070,9 @@ packages:
browser-or-node@2.1.1: browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
browserslist@4.28.1: browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -4134,6 +4314,9 @@ packages:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'} engines: {node: '>=14'}
js-md5@0.8.3:
resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==}
js-tokens@10.0.0: js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -4286,6 +4469,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -4696,6 +4882,9 @@ packages:
pdf-lib@1.17.1: pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pdfkit@0.18.0:
resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==}
peberminta@0.9.0: peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -4757,6 +4946,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
png-js@1.1.0:
resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==}
possible-typed-array-names@1.1.0: possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5386,6 +5578,10 @@ packages:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6648,81 +6844,177 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.33.5': '@img/sharp-darwin-arm64@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4 '@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true optional: true
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.33.5': '@img/sharp-darwin-x64@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4 '@img/sharp-libvips-darwin-x64': 1.0.4
optional: true optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4': '@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4': '@img/sharp-libvips-darwin-x64@1.0.4':
optional: true optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4': '@img/sharp-libvips-linux-arm64@1.0.4':
optional: true optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5': '@img/sharp-libvips-linux-arm@1.0.5':
optional: true optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4': '@img/sharp-libvips-linux-s390x@1.0.4':
optional: true optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4': '@img/sharp-libvips-linux-x64@1.0.4':
optional: true optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4': '@img/sharp-libvips-linuxmusl-arm64@1.0.4':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4': '@img/sharp-libvips-linuxmusl-x64@1.0.4':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.33.5': '@img/sharp-linux-arm64@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4 '@img/sharp-libvips-linux-arm64': 1.0.4
optional: true optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.33.5': '@img/sharp-linux-arm@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5 '@img/sharp-libvips-linux-arm': 1.0.5
optional: true optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.33.5': '@img/sharp-linux-s390x@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4 '@img/sharp-libvips-linux-s390x': 1.0.4
optional: true optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.33.5': '@img/sharp-linux-x64@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4 '@img/sharp-libvips-linux-x64': 1.0.4
optional: true optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5': '@img/sharp-linuxmusl-arm64@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4 '@img/sharp-libvips-linuxmusl-arm64': 1.0.4
optional: true optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.33.5': '@img/sharp-linuxmusl-x64@0.33.5':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4 '@img/sharp-libvips-linuxmusl-x64': 1.0.4
optional: true optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.33.5': '@img/sharp-wasm32@0.33.5':
dependencies: dependencies:
'@emnapi/runtime': 1.9.0 '@emnapi/runtime': 1.9.0
optional: true optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.9.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.33.5': '@img/sharp-win32-ia32@0.33.5':
optional: true optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.33.5': '@img/sharp-win32-x64@0.33.5':
optional: true optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ioredis/commands@1.5.0': {} '@ioredis/commands@1.5.0': {}
'@ioredis/commands@1.5.1': {} '@ioredis/commands@1.5.1': {}
@@ -6816,8 +7108,12 @@ snapshots:
'@next/swc-win32-x64-msvc@15.1.0': '@next/swc-win32-x64-msvc@15.1.0':
optional: true optional: true
'@noble/ciphers@1.3.0': {}
'@noble/ciphers@2.1.1': {} '@noble/ciphers@2.1.1': {}
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.0.1': {} '@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -7747,6 +8043,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.15 '@types/node': 22.19.15
'@types/pdfkit@0.17.6':
dependencies:
'@types/node': 22.19.15
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies: dependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@@ -8283,6 +8583,8 @@ snapshots:
dependencies: dependencies:
bare-path: 3.0.0 bare-path: 3.0.0
base64-js@0.0.8: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
base64id@2.0.0: {} base64id@2.0.0: {}
@@ -8363,6 +8665,10 @@ snapshots:
browser-or-node@2.1.1: {} browser-or-node@2.1.1: {}
browserify-zlib@0.2.0:
dependencies:
pako: 1.0.11
browserslist@4.28.1: browserslist@4.28.1:
dependencies: dependencies:
baseline-browser-mapping: 2.10.8 baseline-browser-mapping: 2.10.8
@@ -9774,6 +10080,8 @@ snapshots:
js-cookie@3.0.5: {} js-cookie@3.0.5: {}
js-md5@0.8.3: {}
js-tokens@10.0.0: {} js-tokens@10.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -9894,6 +10202,11 @@ snapshots:
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkify-it@5.0.0: linkify-it@5.0.0:
@@ -10308,6 +10621,15 @@ snapshots:
pako: 1.0.11 pako: 1.0.11
tslib: 1.14.1 tslib: 1.14.1
pdfkit@0.18.0:
dependencies:
'@noble/ciphers': 1.3.0
'@noble/hashes': 1.8.0
fontkit: 2.0.4
js-md5: 0.8.3
linebreak: 1.1.0
png-js: 1.1.0
peberminta@0.9.0: {} peberminta@0.9.0: {}
performance-now@2.1.0: {} performance-now@2.1.0: {}
@@ -10386,6 +10708,10 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.2
png-js@1.1.0:
dependencies:
browserify-zlib: 0.2.0
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.8): postcss-import@15.1.0(postcss@8.5.8):
@@ -11175,6 +11501,37 @@ snapshots:
'@img/sharp-win32-x64': 0.33.5 '@img/sharp-win32-x64': 0.33.5
optional: true optional: true
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; import { and, eq, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
@@ -106,7 +106,12 @@ export async function GET(request: Request): Promise<Response> {
// invalid data downstream. // invalid data downstream.
for (const row of list) { for (const row of list) {
if (row.Status !== 'Available' && row.Status !== 'Under Offer' && row.Status !== 'Sold') { if (row.Status !== 'Available' && row.Status !== 'Under Offer' && row.Status !== 'Sold') {
logger.error({ row }, 'Public berth status out of range'); // Log just the identifying fields - never the full berth row, which
// includes price + amenity columns that don't belong in error logs.
logger.error(
{ berthId: row.Id, mooringNumber: row['Mooring Number'], status: row.Status },
'Public berth status out of range',
);
return NextResponse.json( return NextResponse.json(
{ error: 'internal', detail: 'berth status enum drift' }, { error: 'internal', detail: 'berth status enum drift' },
{ status: 500 }, { status: 500 },
@@ -139,7 +144,3 @@ function emptyPageInfo() {
isLastPage: true as const, isLastPage: true as const,
}; };
} }
// Suppress the `sql` import unused-warning when no inline raw SQL appears
// further down (helper kept for future where-clause extensions).
void sql;

View File

@@ -2,21 +2,67 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
import { exportPdf } from '@/lib/services/expense-export'; import { streamExpensePdf } from '@/lib/services/expense-pdf.service';
import { listExpensesSchema } from '@/lib/validators/expenses'; import { exportExpensePdfSchema } from '@/lib/validators/expenses';
/**
* POST /api/v1/expenses/export/pdf
*
* Streams the expense report PDF directly to the client — body bytes
* leave the process as pdfkit writes them, so the route is safe for
* hundreds of expenses with full-resolution receipt images. See
* `expense-pdf.service.ts` for the memory-budget design.
*
* Request body shape (zod-validated):
* {
* expenseIds?: string[] // explicit selection (preferred)
* filter?: {...} // listExpenses-style filter when no ids
* options: {
* documentName, subheader?, groupBy, includeReceipts,
* includeReceiptContents, includeSummary, includeDetails,
* includeProcessingFee, targetCurrency, pageFormat,
* }
* }
*
* Response: `application/pdf` binary stream + Content-Disposition.
*/
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const POST = withAuth( export const POST = withAuth(
withPermission('expenses', 'view', async (req, ctx) => { withPermission('expenses', 'export', async (req, ctx) => {
try { try {
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const query = listExpensesSchema.parse(body); const input = exportExpensePdfSchema.parse(body);
const pdf = await exportPdf(ctx.portId, query);
return new NextResponse(Buffer.from(pdf), { const { stream, suggestedFilename } = await streamExpensePdf({
portId: ctx.portId,
expenseIds: input.expenseIds,
filter: input.filter
? {
dateFrom: input.filter.dateFrom ?? null,
dateTo: input.filter.dateTo ?? null,
category: input.filter.category ?? null,
paymentStatus: input.filter.paymentStatus ?? null,
payer: input.filter.payer ?? null,
includeArchived: input.filter.includeArchived ?? false,
}
: undefined,
options: input.options,
});
// NextResponse extends Response; passing a ReadableStream as the
// body keeps the streaming semantics. The wrapper's RouteHandler
// type expects NextResponse so we use it explicitly.
return new NextResponse(stream, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="expenses-${Date.now()}.pdf"`, 'Content-Disposition': `attachment; filename="${suggestedFilename}"`,
// The PDF is generated on the fly per-request and includes
// potentially-sensitive expense data; never cache.
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1 @@
ALTER TABLE "expenses" ADD COLUMN "no_receipt_acknowledged" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -232,6 +232,13 @@
"when": 1777946048910, "when": 1777946048910,
"tag": "0032_brochures_one_default_per_port_and_storage_fixes", "tag": "0032_brochures_one_default_per_port_and_storage_fixes",
"breakpoints": true "breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1777948521076,
"tag": "0033_expense_no_receipt_acknowledged",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,7 @@
import { import {
pgTable, pgTable,
text, text,
boolean,
numeric, numeric,
integer, integer,
timestamp, timestamp,
@@ -36,6 +37,14 @@ export const expenses = pgTable(
expenseDate: timestamp('expense_date', { withTimezone: true }).notNull(), expenseDate: timestamp('expense_date', { withTimezone: true }).notNull(),
description: text('description'), description: text('description'),
receiptFileIds: text('receipt_file_ids').array(), // references to files table receiptFileIds: text('receipt_file_ids').array(), // references to files table
/**
* True when the rep deliberately created the expense WITHOUT a receipt
* (e.g. the receipt was lost or never issued). Surfaces a warning at
* creation time AND in the PDF export — the legacy parent-company flow
* may refuse to reimburse expenses without proof, so the warning is
* load-bearing for ops.
*/
noReceiptAcknowledged: boolean('no_receipt_acknowledged').notNull().default(false),
paymentStatus: text('payment_status').default('unpaid'), // unpaid, paid, partial paymentStatus: text('payment_status').default('unpaid'), // unpaid, paid, partial
paymentDate: date('payment_date'), paymentDate: date('payment_date'),
paymentReference: text('payment_reference'), paymentReference: text('payment_reference'),

View File

@@ -190,19 +190,42 @@ export interface OcrAdapter {
recognize(buffer: Buffer): Promise<{ text: string; confidence: number }>; recognize(buffer: Buffer): Promise<{ text: string; confidence: number }>;
} }
/** Hard cap on Tesseract OCR runtime. A crafted PDF rasterizing to
* high-resolution noise can pin the process indefinitely (CPU bomb).
* 30 seconds covers the legitimate single-page-spec case by a wide
* margin while bounding the worst-case worker hold-time. The AI
* fallback tier handles cases where OCR couldn't finish. */
const OCR_TIMEOUT_MS = 30_000;
/** Default adapter — dynamically imports tesseract.js so the WASM bundle isn't /** Default adapter — dynamically imports tesseract.js so the WASM bundle isn't
* pulled into client builds. */ * pulled into client builds. */
async function defaultOcrAdapter(): Promise<OcrAdapter> { async function defaultOcrAdapter(): Promise<OcrAdapter> {
return { return {
recognize: async (buffer: Buffer) => { recognize: async (buffer: Buffer) => {
const tesseract = await import('tesseract.js'); const tesseract = await import('tesseract.js');
// Tesseract handles PDF inputs by rasterizing the first page; for our // Race the OCR against a timeout so a runaway recognition can't
// single-page spec sheets that's sufficient. // hold the worker forever. The race-loser pattern doesn't
const result = await tesseract.recognize(buffer, 'eng'); // actually cancel Tesseract (no AbortController support), but it
return { // does free the awaiter so the caller can fall through to AI.
text: result.data.text ?? '', let timeoutHandle: NodeJS.Timeout | undefined;
confidence: typeof result.data.confidence === 'number' ? result.data.confidence : 0, const timeout = new Promise<{ text: string; confidence: number }>((_, reject) => {
}; timeoutHandle = setTimeout(
() => reject(new Error(`Tesseract OCR exceeded ${OCR_TIMEOUT_MS}ms timeout`)),
OCR_TIMEOUT_MS,
);
});
try {
const result = await Promise.race([
tesseract.recognize(buffer, 'eng').then((r) => ({
text: r.data.text ?? '',
confidence: typeof r.data.confidence === 'number' ? r.data.confidence : 0,
})),
timeout,
]);
return result;
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
}
}, },
}; };
} }

View File

@@ -531,15 +531,22 @@ export async function listBerthPdfVersions(berthId: string): Promise<BerthPdfVer
.orderBy(desc(berthPdfVersions.versionNumber)); .orderBy(desc(berthPdfVersions.versionNumber));
const backend = await getStorageBackend(); const backend = await getStorageBackend();
const out: BerthPdfVersionListItem[] = []; // Presign in parallel — for an S3 backend each call is a separate HTTP
for (const row of rows) { // round-trip, so a 20-version berth used to take 20× the latency in
const parseEngine = (row.parseResults as { engine?: ParserEngine } | null)?.engine ?? null; // the sequential loop. Promise.all collapses to ~1× round-trip.
const presigned = await backend.presignDownload(row.storageKey, { const presigned = await Promise.all(
rows.map((row) =>
backend.presignDownload(row.storageKey, {
expirySeconds: 900, expirySeconds: 900,
filename: row.fileName, filename: row.fileName,
contentType: 'application/pdf', contentType: 'application/pdf',
}); }),
out.push({ ),
);
return rows.map((row, i) => {
const parseEngine = (row.parseResults as { engine?: ParserEngine } | null)?.engine ?? null;
return {
id: row.id, id: row.id,
versionNumber: row.versionNumber, versionNumber: row.versionNumber,
fileName: row.fileName, fileName: row.fileName,
@@ -547,12 +554,11 @@ export async function listBerthPdfVersions(berthId: string): Promise<BerthPdfVer
uploadedBy: row.uploadedBy, uploadedBy: row.uploadedBy,
uploadedAt: row.uploadedAt, uploadedAt: row.uploadedAt,
isCurrent: berthRow.currentPdfVersionId === row.id, isCurrent: berthRow.currentPdfVersionId === row.id,
downloadUrl: presigned.url, downloadUrl: presigned[i]!.url,
downloadUrlExpiresAt: presigned.expiresAt, downloadUrlExpiresAt: presigned[i]!.expiresAt,
parseEngine, parseEngine,
};
}); });
}
return out;
} }
/** /**

View File

@@ -55,15 +55,9 @@ export async function listBrochures(
if (baseRows.length === 0) return []; if (baseRows.length === 0) return [];
const ids = baseRows.map((r) => r.id); const ids = baseRows.map((r) => r.id);
const versions = await db // One round-trip fetches every version for the page, ordered newest-first
.select() // so the per-row `currentVersion` lookup below is just `[0]`.
.from(brochureVersions) const allVersions = await db.query.brochureVersions.findMany({
.where(eq(brochureVersions.brochureId, ids[0]!));
// Pull all versions for these brochures in one round trip.
const allVersions =
ids.length === 1
? versions
: await db.query.brochureVersions.findMany({
where: (bv, { inArray }) => inArray(bv.brochureId, ids), where: (bv, { inArray }) => inArray(bv.brochureId, ids),
orderBy: [desc(brochureVersions.uploadedAt)], orderBy: [desc(brochureVersions.uploadedAt)],
}); });

View File

@@ -8,7 +8,9 @@ import { logger } from '@/lib/logger';
import type { ListExpensesInput } from '@/lib/validators/expenses'; import type { ListExpensesInput } from '@/lib/validators/expenses';
async function fetchAllExpenses(portId: string, query: ListExpensesInput) { 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) { if (!query.includeArchived) {
conditions.push(isNull(expenses.archivedAt) as unknown as ReturnType<typeof eq>); 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>); conditions.push(eq(expenses.payer, query.payer) as ReturnType<typeof eq>);
} }
if (query.dateFrom) { 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) { 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) { if (query.search) {
conditions.push( conditions.push(
@@ -81,49 +87,15 @@ export async function exportCsv(portId: string, query: ListExpensesInput): Promi
return [headers.join(','), ...csvRows].join('\n'); return [headers.join(','), ...csvRows].join('\n');
} }
export async function exportPdf(portId: string, query: ListExpensesInput): Promise<Uint8Array> { /**
const rows = await fetchAllExpenses(portId, query); * Legacy text-only PDF export superseded by the streaming
* `streamExpensePdf` in `src/lib/services/expense-pdf.service.ts`.
const template = { * The new service supports receipt-image embedding, sharp resize for
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] }, * stupidly-large attachments, and streaming output so hundreds of
schemas: [ * expenses no longer OOM the process.
[ *
{ * See `src/app/api/v1/expenses/export/pdf/route.ts` for the live route.
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);
}
export async function exportParentCompany( export async function exportParentCompany(
portId: string, portId: string,

View File

@@ -0,0 +1,899 @@
/**
* 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 } 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 <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;
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<ProcessedExpense[]> {
// Resolve rate ONCE per source currency (cached by getRate). Avoids the
// legacy code's per-row API call.
const rateCache = new Map<string, number>();
const ensureRate = async (from: string, to: string): Promise<number> => {
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<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)];
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<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);
// 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);
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<string, ResolvedFile>,
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');
}

View File

@@ -107,6 +107,7 @@ export async function createExpense(portId: string, data: CreateExpenseInput, me
expenseDate: data.expenseDate, expenseDate: data.expenseDate,
description: data.description, description: data.description,
receiptFileIds: data.receiptFileIds ?? [], receiptFileIds: data.receiptFileIds ?? [],
noReceiptAcknowledged: data.noReceiptAcknowledged ?? false,
paymentStatus: data.paymentStatus, paymentStatus: data.paymentStatus,
paymentDate: data.paymentDate ?? null, paymentDate: data.paymentDate ?? null,
paymentReference: data.paymentReference ?? null, paymentReference: data.paymentReference ?? null,

View File

@@ -2,7 +2,12 @@ import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers'; import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
export const createExpenseSchema = z.object({ /**
* Inner-shape ZodObject — kept exported (without .refine wrapping) so
* `updateExpenseSchema` can still call `.partial()`. The `.refine()` rule
* for "receipt or acknowledgement" is applied via `createExpenseSchema`.
*/
export const createExpenseShape = z.object({
establishmentName: z.string().max(200).optional(), establishmentName: z.string().max(200).optional(),
amount: z.coerce.number().positive(), amount: z.coerce.number().positive(),
currency: z.string().length(3).default('USD'), currency: z.string().length(3).default('USD'),
@@ -12,13 +17,33 @@ export const createExpenseSchema = z.object({
expenseDate: z.coerce.date(), expenseDate: z.coerce.date(),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
receiptFileIds: z.array(z.string()).optional(), receiptFileIds: z.array(z.string()).optional(),
/**
* Set to `true` when the rep deliberately creates an expense without a
* receipt. The UI shows a non-blocking warning that surfaces both at
* creation time and again in the PDF export. Without this flag, the
* server rejects an expense submitted with no `receiptFileIds` so reps
* can't accidentally lose-receipt by mistake.
*/
noReceiptAcknowledged: z.boolean().optional().default(false),
paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'), paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'),
paymentDate: z.string().optional(), paymentDate: z.string().optional(),
paymentReference: z.string().optional(), paymentReference: z.string().optional(),
paymentNotes: z.string().optional(), paymentNotes: z.string().optional(),
}); });
export const updateExpenseSchema = createExpenseSchema.partial(); export const createExpenseSchema = createExpenseShape.refine(
(v) => (v.receiptFileIds && v.receiptFileIds.length > 0) || v.noReceiptAcknowledged === true,
{
message:
'Receipt required. Tick "I have no receipt for this expense" if you understand it may not be reimbursed.',
path: ['receiptFileIds'],
},
);
// Update accepts partial fields and skips the create-time receipt-or-ack
// rule (the row already exists and may legitimately be edited without
// touching receipts).
export const updateExpenseSchema = createExpenseShape.partial();
export const listExpensesSchema = baseListQuerySchema.extend({ export const listExpensesSchema = baseListQuerySchema.extend({
category: z.string().optional(), category: z.string().optional(),
@@ -29,6 +54,45 @@ export const listExpensesSchema = baseListQuerySchema.extend({
payer: z.string().optional(), payer: z.string().optional(),
}); });
/**
* Body for `POST /api/v1/expenses/export/pdf`. Mirrors the legacy
* `PDFOptions` shape from the Nuxt client-portal so reps can re-use the
* same mental model. `expenseIds` selects an explicit subset; when
* absent, the listExpenses-style filter is used to gather rows.
*
* Limits are deliberate:
* - max 1000 expenseIds so a runaway selection can't queue an OOM-able
* receipt-fetch loop (see expense-pdf.service.ts).
* - documentName is sanitized at the service layer for the filename;
* the validator only enforces a sane upper bound.
*/
export const exportExpensePdfSchema = z.object({
expenseIds: z.array(z.string()).max(1000).optional(),
filter: z
.object({
dateFrom: z.string().optional().nullable(),
dateTo: z.string().optional().nullable(),
category: z.string().optional().nullable(),
paymentStatus: z.string().optional().nullable(),
payer: z.string().optional().nullable(),
includeArchived: z.boolean().optional(),
})
.optional(),
options: z.object({
documentName: z.string().min(1).max(200),
subheader: z.string().max(300).optional(),
groupBy: z.enum(['none', 'payer', 'category', 'date']).default('none'),
includeReceipts: z.boolean().default(false),
includeReceiptContents: z.boolean().default(false),
includeSummary: z.boolean().default(true),
includeDetails: z.boolean().default(true),
includeProcessingFee: z.boolean().default(false),
targetCurrency: z.enum(['USD', 'EUR']).default('EUR'),
pageFormat: z.enum(['A4', 'Letter', 'Legal']).default('A4'),
}),
});
export type CreateExpenseInput = z.infer<typeof createExpenseSchema>; export type CreateExpenseInput = z.infer<typeof createExpenseSchema>;
export type UpdateExpenseInput = z.infer<typeof updateExpenseSchema>; export type UpdateExpenseInput = z.infer<typeof updateExpenseSchema>;
export type ListExpensesInput = z.infer<typeof listExpensesSchema>; export type ListExpensesInput = z.infer<typeof listExpensesSchema>;
export type ExportExpensePdfInput = z.infer<typeof exportExpensePdfSchema>;