diff --git a/src/lib/pdf/templates/parent-company-expense.tsx b/src/lib/pdf/templates/parent-company-expense.tsx
new file mode 100644
index 00000000..1eac035b
--- /dev/null
+++ b/src/lib/pdf/templates/parent-company-expense.tsx
@@ -0,0 +1,91 @@
+import { DataTable, DocumentShell, KeyValueGrid, Section } from '@/lib/pdf/brand-kit';
+
+export interface ParentCompanyRow {
+ date: string;
+ establishment: string;
+ category: string;
+ amountEur: number;
+}
+
+export interface ParentCompanyExpensePdfProps {
+ portName: string;
+ logoBuffer: Buffer | null;
+ rows: ParentCompanyRow[];
+ subtotal: number;
+ managementFee: number;
+ total: number;
+ /** Optional ISO date range for the meta line. */
+ dateFrom?: string;
+ dateTo?: string;
+ /** Whether the EUR rate lookup succeeded. Renders a small footnote if false. */
+ rateAvailable?: boolean;
+}
+
+function fmtEur(n: number): string {
+ return `EUR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+}
+
+export function ParentCompanyExpensePdf({
+ portName,
+ logoBuffer,
+ rows,
+ subtotal,
+ managementFee,
+ total,
+ dateFrom,
+ dateTo,
+ rateAvailable = true,
+}: ParentCompanyExpensePdfProps) {
+ const meta =
+ dateFrom || dateTo
+ ? `Range: ${dateFrom ?? '—'} → ${dateTo ?? 'today'} · ${rows.length} entries`
+ : `${rows.length} entries`;
+
+ return (
+
+
+
+ {!rateAvailable ? (
+
+ ) : null}
+
+
+
+
+ columns={[
+ { header: 'Date', flex: 1, render: (r) => r.date },
+ { header: 'Establishment', flex: 3, render: (r) => r.establishment },
+ { header: 'Category', flex: 2, render: (r) => r.category },
+ { header: 'Amount', flex: 2, align: 'right', render: (r) => fmtEur(r.amountEur) },
+ ]}
+ rows={rows}
+ totals={['Total', '', '', fmtEur(total)]}
+ emptyMessage="No expenses in the selected range."
+ />
+
+
+ );
+}
diff --git a/src/lib/services/expense-export.ts b/src/lib/services/expense-export.tsx
similarity index 72%
rename from src/lib/services/expense-export.ts
rename to src/lib/services/expense-export.tsx
index cca80dc1..d12d32b8 100644
--- a/src/lib/services/expense-export.ts
+++ b/src/lib/services/expense-export.tsx
@@ -2,7 +2,10 @@ import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
-import { generatePdf } from '@/lib/pdf/generate';
+import { ports } from '@/lib/db/schema/ports';
+import { renderPdf } from '@/lib/pdf/render';
+import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
+import { ParentCompanyExpensePdf } from '@/lib/pdf/templates/parent-company-expense';
import { getRate } from '@/lib/services/currency';
import { logger } from '@/lib/logger';
import type { ListExpensesInput } from '@/lib/validators/expenses';
@@ -100,7 +103,7 @@ export async function exportCsv(portId: string, query: ListExpensesInput): Promi
export async function exportParentCompany(
portId: string,
query: ListExpensesInput,
-): Promise {
+): Promise {
// BR-043: Convert all amounts to EUR, add 5% management fee
const rows = await fetchAllExpenses(portId, query);
const eurRate = await getRate('USD', 'EUR');
@@ -115,7 +118,7 @@ export async function exportParentCompany(
const amountUsd = r.amountUsd ? Number(r.amountUsd) : Number(r.amount);
const amountEur = Number((amountUsd * rate).toFixed(2));
return {
- date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '',
+ date: r.expenseDate ? (new Date(r.expenseDate).toISOString().split('T')[0] ?? '') : '',
establishment: r.establishmentName ?? '-',
category: r.category ?? '-',
amountEur,
@@ -126,58 +129,22 @@ export async function exportParentCompany(
const fee = Number((subtotal * 0.05).toFixed(2));
const total = Number((subtotal + fee).toFixed(2));
- 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: 230,
- fontSize: 8,
- fontColor: '#000000',
- },
- {
- name: 'summary',
- type: 'text',
- position: { x: 10, y: 260 },
- width: 190,
- height: 30,
- fontSize: 10,
- fontColor: '#000000',
- },
- ],
- ],
- };
+ const [port, logo] = await Promise.all([
+ db.query.ports.findFirst({ where: eq(ports.id, portId) }),
+ resolvePortLogo(portId),
+ ]);
- const lines = convertedRows.map(
- (r) => `${r.date} | ${r.establishment} | ${r.category} | EUR ${r.amountEur.toFixed(2)}`,
+ return renderPdf(
+ ,
);
-
- const summary = [
- `Subtotal: EUR ${subtotal.toFixed(2)}`,
- `Management Fee (5%): EUR ${fee.toFixed(2)}`,
- `Total: EUR ${total.toFixed(2)}`,
- ].join('\n');
-
- const inputs = [
- {
- title: 'Parent Company Expense Report (EUR)',
- content: lines.join('\n'),
- summary,
- },
- ];
-
- return generatePdf(template as unknown as Parameters[0], inputs);
}
diff --git a/src/lib/services/expense-pdf.service.ts b/src/lib/services/expense-pdf.service.ts
index a60ab631..467ed92e 100644
--- a/src/lib/services/expense-pdf.service.ts
+++ b/src/lib/services/expense-pdf.service.ts
@@ -41,7 +41,9 @@ import sharp from 'sharp';
import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { files } from '@/lib/db/schema/documents';
+import { ports } from '@/lib/db/schema/ports';
import { getRate } from '@/lib/services/currency';
+import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
@@ -487,6 +489,12 @@ export async function streamExpensePdf(
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 ?? [])
@@ -513,7 +521,7 @@ export async function streamExpensePdf(
// / doc.emit('error') and surface to the consumer of the stream.
void (async () => {
try {
- addHeader(doc, opts);
+ addHeader(doc, { ...opts, portName: port?.name, logoBuffer: logo.buffer });
if (opts.includeSummary) addSummaryBox(doc, totals, opts);
if (opts.includeDetails) addExpenseTable(doc, processed, opts);
@@ -544,15 +552,64 @@ export async function streamExpensePdf(
// ─── Page sections ──────────────────────────────────────────────────────────
-function addHeader(doc: PDFKit.PDFDocument, opts: { documentName: string; subheader?: string }) {
+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(24)
+ .fontSize(13)
.font('Helvetica-Bold')
- .fillColor('#000000')
- .text(opts.documentName, { align: 'center' });
+ .text(opts.documentName, pageWidth / 2, (bandHeight - 13) / 2, {
+ align: 'right',
+ width: pageWidth - pageWidth / 2 - bandPaddingX,
+ lineBreak: false,
+ });
+ doc.restore();
+
+ // Move past the band and render the subheader below in black on white.
+ doc.y = bandHeight + 12;
const subheader = opts.subheader ?? `Generated on ${new Date().toLocaleDateString()}`;
- doc.fontSize(12).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
- doc.fillColor('#000000').moveDown(1);
+ 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(
diff --git a/tests/unit/parent-company-export.test.tsx b/tests/unit/parent-company-export.test.tsx
new file mode 100644
index 00000000..a25606ab
--- /dev/null
+++ b/tests/unit/parent-company-export.test.tsx
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+
+import { renderPdf } from '@/lib/pdf/render';
+import { ParentCompanyExpensePdf } from '@/lib/pdf/templates/parent-company-expense';
+
+describe('parent-company expense template', () => {
+ it('renders with multiple rows + totals', async () => {
+ const bytes = await renderPdf(
+ ,
+ );
+ expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
+ }, 30_000);
+
+ it('renders the rate-unavailable footnote', async () => {
+ const bytes = await renderPdf(
+ ,
+ );
+ expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
+ }, 30_000);
+});