feat(expense-export): parent-company react-pdf + pdfkit brand header
Phase 1 / commit 10 of 14 — migrates the pdfme-based parent-company
expense export to react-pdf and adds a shared brand header to the
pdfkit-based streaming expense PDF so both surfaces match the rest of
the internal-only PDF family.
parent-company-expense.tsx:
Summary KV grid (entry count, subtotal, fee, total) + entries table
with right-aligned EUR amounts and a totals row. Footnote rendered
when the EUR rate lookup falls through to the 1:1 USD:EUR fallback.
expense-export.tsx (renamed .ts -> .tsx):
- exportParentCompany now renders the react-pdf template via
resolvePortLogo() + renderPdf()
- dropped the inline pdfme template object (was the last pdfme caller
in this file)
- return type widened from Uint8Array to Buffer; caller already wraps
in Buffer.from() so no API change downstream
expense-pdf.service.ts (the pdfkit streaming engine — unchanged):
- addHeader() now draws a dark slate band matching the brand-kit
header band, with the port logo letterboxed on the left and the
document title right-aligned. Falls back to text port-name if the
logo image is missing or can't be decoded by pdfkit
- port + logo resolved once per export via Promise.all
- subheader stays beneath the band in muted grey, same as before
- streaming behavior + receipt embedding + sharp compression
untouched — the only change is the visual treatment of the header
Old pdfme inline template deleted along with the generatePdf import.
After this commit, the only remaining pdfme imports are in:
invoice-template.ts, tiptap-to-pdfme.ts, eoi-standard-inapp.ts, and
document-templates.ts (lines 516-522). All four are removed in
commits 11-12.
1319/1319 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
150
src/lib/services/expense-export.tsx
Normal file
150
src/lib/services/expense-export.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { expenses } from '@/lib/db/schema/financial';
|
||||
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';
|
||||
|
||||
async function fetchAllExpenses(portId: string, query: ListExpensesInput) {
|
||||
const conditions: ReturnType<typeof eq>[] = [
|
||||
eq(expenses.portId, portId) as ReturnType<typeof eq>,
|
||||
];
|
||||
|
||||
if (!query.includeArchived) {
|
||||
conditions.push(isNull(expenses.archivedAt) as unknown as ReturnType<typeof eq>);
|
||||
}
|
||||
if (query.category) {
|
||||
conditions.push(eq(expenses.category, query.category) as ReturnType<typeof eq>);
|
||||
}
|
||||
if (query.paymentStatus) {
|
||||
conditions.push(eq(expenses.paymentStatus, query.paymentStatus) as ReturnType<typeof eq>);
|
||||
}
|
||||
if (query.currency) {
|
||||
conditions.push(eq(expenses.currency, query.currency) as ReturnType<typeof eq>);
|
||||
}
|
||||
if (query.payer) {
|
||||
conditions.push(eq(expenses.payer, query.payer) as ReturnType<typeof eq>);
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
conditions.push(
|
||||
gte(expenses.expenseDate, new Date(query.dateFrom)) as unknown as ReturnType<typeof eq>,
|
||||
);
|
||||
}
|
||||
if (query.dateTo) {
|
||||
conditions.push(
|
||||
lte(expenses.expenseDate, new Date(query.dateTo)) as unknown as ReturnType<typeof eq>,
|
||||
);
|
||||
}
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(expenses.establishmentName, `%${query.search}%`),
|
||||
ilike(expenses.description, `%${query.search}%`),
|
||||
) as unknown as ReturnType<typeof eq>,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(expenses)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
export async function exportCsv(portId: string, query: ListExpensesInput): Promise<string> {
|
||||
const rows = await fetchAllExpenses(portId, query);
|
||||
|
||||
const headers = [
|
||||
'Date',
|
||||
'Establishment',
|
||||
'Category',
|
||||
'Amount',
|
||||
'Currency',
|
||||
'Amount USD',
|
||||
'Payment Status',
|
||||
'Payment Method',
|
||||
'Description',
|
||||
];
|
||||
|
||||
const csvRows = 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.amountUsd ?? 'N/A',
|
||||
r.paymentStatus ?? '',
|
||||
r.paymentMethod ?? '',
|
||||
(r.description ?? '').replace(/"/g, '""'),
|
||||
]
|
||||
.map((v) => `"${v}"`)
|
||||
.join(',');
|
||||
});
|
||||
|
||||
return [headers.join(','), ...csvRows].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy text-only PDF export superseded by the streaming
|
||||
* `streamExpensePdf` in `src/lib/services/expense-pdf.service.ts`.
|
||||
* The new service supports receipt-image embedding, sharp resize for
|
||||
* stupidly-large attachments, and streaming output so hundreds of
|
||||
* expenses no longer OOM the process.
|
||||
*
|
||||
* See `src/app/api/v1/expenses/export/pdf/route.ts` for the live route.
|
||||
*/
|
||||
|
||||
export async function exportParentCompany(
|
||||
portId: string,
|
||||
query: ListExpensesInput,
|
||||
): Promise<Buffer> {
|
||||
// BR-043: Convert all amounts to EUR, add 5% management fee
|
||||
const rows = await fetchAllExpenses(portId, query);
|
||||
const eurRate = await getRate('USD', 'EUR');
|
||||
|
||||
if (!eurRate) {
|
||||
logger.warn('EUR rate unavailable for parent company export, using 1:1 fallback');
|
||||
}
|
||||
|
||||
const rate = eurRate ?? 1;
|
||||
|
||||
const convertedRows = rows.map((r) => {
|
||||
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] ?? '') : '',
|
||||
establishment: r.establishmentName ?? '-',
|
||||
category: r.category ?? '-',
|
||||
amountEur,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotal = convertedRows.reduce((sum, r) => sum + r.amountEur, 0);
|
||||
const fee = Number((subtotal * 0.05).toFixed(2));
|
||||
const total = Number((subtotal + fee).toFixed(2));
|
||||
|
||||
const [port, logo] = await Promise.all([
|
||||
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
|
||||
resolvePortLogo(portId),
|
||||
]);
|
||||
|
||||
return renderPdf(
|
||||
<ParentCompanyExpensePdf
|
||||
portName={port?.name ?? 'Port Nimara'}
|
||||
logoBuffer={logo.buffer}
|
||||
rows={convertedRows}
|
||||
subtotal={subtotal}
|
||||
managementFee={fee}
|
||||
total={total}
|
||||
dateFrom={query.dateFrom}
|
||||
dateTo={query.dateTo}
|
||||
rateAvailable={Boolean(eurRate)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user