import Papa from 'papaparse'; 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[] = [ eq(expenses.portId, portId) as ReturnType, ]; if (!query.includeArchived) { conditions.push(isNull(expenses.archivedAt) as unknown as ReturnType); } if (query.category) { conditions.push(eq(expenses.category, query.category) as ReturnType); } if (query.paymentStatus) { conditions.push(eq(expenses.paymentStatus, query.paymentStatus) as ReturnType); } if (query.currency) { conditions.push(eq(expenses.currency, query.currency) as ReturnType); } if (query.payer) { conditions.push(eq(expenses.payer, query.payer) as ReturnType); } if (query.dateFrom) { conditions.push( gte(expenses.expenseDate, new Date(query.dateFrom)) as unknown as ReturnType, ); } if (query.dateTo) { conditions.push( lte(expenses.expenseDate, new Date(query.dateTo)) as unknown as ReturnType, ); } if (query.search) { conditions.push( or( ilike(expenses.establishmentName, `%${query.search}%`), ilike(expenses.description, `%${query.search}%`), ) as unknown as ReturnType, ); } return db .select() .from(expenses) .where(and(...conditions)); } export async function exportCsv(portId: string, query: ListExpensesInput): Promise { const rows = await fetchAllExpenses(portId, query); // papaparse handles all the CSV edge cases (commas in fields, embedded // quotes, newlines, BOM) that the hand-rolled escape-and-quote version // missed. Keyed objects let us define column order via `columns` and // get matching headers for free. return Papa.unparse( rows.map((r) => ({ Date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '', Establishment: r.establishmentName ?? '', Category: r.category ?? '', Amount: r.amount, Currency: r.currency, 'Amount USD': r.amountUsd ?? 'N/A', 'Payment Status': r.paymentStatus ?? '', 'Payment Method': r.paymentMethod ?? '', Description: r.description ?? '', })), { columns: [ 'Date', 'Establishment', 'Category', 'Amount', 'Currency', 'Amount USD', 'Payment Status', 'Payment Method', 'Description', ], }, ); } /** * 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 { // 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), ]); if (!port) { throw new Error(`Cannot render expense export: port ${portId} not found.`); } return renderPdf( , ); }