port-nimara-client-portal/server/api/expenses/generate-pdf.ts

574 lines
16 KiB
TypeScript

import { requireAuth } from '@/server/utils/auth';
import { getExpenseById } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency';
import { createError } from 'h3';
import { formatDate } from '@/utils/dateUtils';
import PDFDocument from 'pdfkit';
import { getMinioClient } from '@/server/utils/minio';
interface PDFOptions {
documentName: string;
subheader?: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeReceiptContents: boolean;
includeSummary: boolean;
includeDetails: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
includeProcessingFee?: boolean;
}
interface Expense {
Id: number;
'Establishment Name': string;
Price: string;
PriceNumber: number;
DisplayPrice: string;
PriceUSD?: number;
ConversionRate?: number;
Payer: string;
Category: string;
'Payment Method': string;
Time: string;
Contents?: string;
Receipt?: any[];
}
export default defineEventHandler(async (event) => {
await requireAuth(event);
const body = await readBody(event);
const { expenseIds, options } = body;
if (!expenseIds || !Array.isArray(expenseIds) || expenseIds.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Expense IDs are required'
});
}
if (!options || !options.documentName) {
throw createError({
statusCode: 400,
statusMessage: 'PDF options with document name are required'
});
}
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
try {
// Fetch expense data
const expenses: Expense[] = [];
for (const expenseId of expenseIds) {
const expense = await getExpenseById(expenseId);
if (expense) {
const processedExpense = await processExpenseWithCurrency(expense);
expenses.push(processedExpense);
}
}
if (expenses.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'No valid expenses found'
});
}
// Calculate totals
const totals = calculateTotals(expenses, options.includeProcessingFee);
console.log('[expenses/generate-pdf] Successfully calculated totals:', totals);
console.log('[expenses/generate-pdf] Options received:', options);
// Generate PDF using PDFKit
const pdfBuffer = await generatePDFWithPDFKit(expenses, options, totals);
// Return PDF as base64 for download
const pdfBase64 = pdfBuffer.toString('base64');
return {
success: true,
data: {
filename: `${options.documentName.replace(/[^a-zA-Z0-9\-_\s]/g, '_')}.pdf`,
content: pdfBase64,
mimeType: 'application/pdf',
size: pdfBuffer.length
}
};
} catch (error: any) {
console.error('[expenses/generate-pdf] Error generating PDF:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to generate PDF'
});
}
});
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false) {
const originalTotal = expenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
const usdTotal = expenses.reduce((sum, exp) => sum + (exp.PriceUSD || exp.PriceNumber || 0), 0);
const processingFee = includeProcessingFee ? originalTotal * 0.05 : 0;
const finalTotal = originalTotal + processingFee;
return {
originalTotal,
usdTotal,
processingFee,
finalTotal,
count: expenses.length
};
}
function getGroupingLabel(groupBy: string): string {
switch (groupBy) {
case 'payer': return 'By Person';
case 'category': return 'By Category';
case 'date': return 'By Date';
default: return 'No Grouping';
}
}
function getPageDimensions(pageFormat: string) {
switch (pageFormat) {
case 'Letter':
return { width: 612, height: 792 }; // 8.5" x 11"
case 'Legal':
return { width: 612, height: 1008 }; // 8.5" x 14"
case 'A4':
default:
return { width: 595, height: 842 }; // A4
}
}
async function generatePDFWithPDFKit(expenses: Expense[], options: PDFOptions, totals: any): Promise<Buffer> {
return new Promise(async (resolve, reject) => {
try {
console.log('[expenses/generate-pdf] Generating PDF with PDFKit...');
const pageDimensions = getPageDimensions(options.pageFormat);
const doc = new PDFDocument({
size: [pageDimensions.width, pageDimensions.height],
margins: { top: 60, bottom: 60, left: 60, right: 60 }
});
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => {
const pdfBuffer = Buffer.concat(chunks);
console.log('[expenses/generate-pdf] PDF generated successfully, size:', pdfBuffer.length, 'bytes');
resolve(pdfBuffer);
});
doc.on('error', reject);
// Add header
addHeader(doc, options);
// Add summary if requested
if (options.includeSummary) {
addSummary(doc, totals, options);
}
// Add expense details if requested
if (options.includeDetails) {
await addExpenseTable(doc, expenses, options);
}
// Add receipt images if requested
if (options.includeReceipts) {
await addReceiptImages(doc, expenses);
}
// Add footer
addFooter(doc);
doc.end();
} catch (error: any) {
console.error('[expenses/generate-pdf] PDFKit error:', error);
reject(new Error(`PDF generation failed: ${error?.message || 'Unknown error'}`));
}
});
}
function addHeader(doc: PDFKit.PDFDocument, options: PDFOptions) {
doc.fontSize(24)
.font('Helvetica-Bold')
.text(options.documentName, { align: 'center' });
if (options.subheader) {
doc.fontSize(16)
.font('Helvetica')
.fillColor('#666666')
.text(options.subheader, { align: 'center' });
}
// Add line separator
const y = doc.y + 10;
doc.moveTo(60, y)
.lineTo(doc.page.width - 60, y)
.strokeColor('#333333')
.lineWidth(2)
.stroke();
doc.y = y + 20;
doc.fillColor('#000000'); // Reset color
}
function addSummary(doc: PDFKit.PDFDocument, totals: any, options: PDFOptions) {
doc.fontSize(18)
.font('Helvetica-Bold')
.text('Summary', { continued: false });
doc.y += 10;
// Summary box
const boxY = doc.y;
const boxHeight = options.includeProcessingFee ? 140 : 120;
doc.rect(60, boxY, doc.page.width - 120, boxHeight)
.fillColor('#f5f5f5')
.fill()
.strokeColor('#dddddd')
.stroke();
doc.fillColor('#000000');
// Summary content
doc.y = boxY + 15;
doc.fontSize(12)
.font('Helvetica');
const leftX = 80;
const rightX = doc.page.width - 200;
doc.text(`Total Expenses:`, leftX, doc.y, { continued: true })
.font('Helvetica-Bold')
.text(` ${totals.count}`, { align: 'left' });
doc.font('Helvetica')
.text(`Subtotal:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(`${totals.originalTotal.toFixed(2)}`, { align: 'left' });
doc.font('Helvetica')
.text(`USD Equivalent:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(` $${totals.usdTotal.toFixed(2)}`, { align: 'left' });
if (options.includeProcessingFee) {
doc.font('Helvetica')
.text(`Processing Fee (5%):`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(`${totals.processingFee.toFixed(2)}`, { align: 'left' });
}
doc.font('Helvetica')
.text(`Final Total:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.fontSize(14)
.text(`${totals.finalTotal.toFixed(2)}`, { align: 'left' });
doc.fontSize(12)
.font('Helvetica')
.text(`Grouping:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(` ${getGroupingLabel(options.groupBy)}`, { align: 'left' });
doc.y = boxY + boxHeight + 20;
}
async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], options: PDFOptions) {
doc.fontSize(18)
.font('Helvetica-Bold')
.text('Expense Details', { continued: false });
doc.y += 15;
const tableTop = doc.y;
const rowHeight = 25;
const fontSize = 9;
// Column definitions
const columns = [
{ header: 'Date', width: 70, x: 60 },
{ header: 'Establishment', width: 120, x: 130 },
{ header: 'Category', width: 60, x: 250 },
{ header: 'Payer', width: 60, x: 310 },
{ header: 'Amount', width: 60, x: 370 },
{ header: 'Payment', width: 50, x: 430 }
];
if (options.includeReceiptContents) {
columns.push({ header: 'Description', width: 85, x: 480 });
}
// Draw table header
doc.fontSize(fontSize + 1)
.font('Helvetica-Bold')
.fillColor('#000000');
// Header background
doc.rect(60, tableTop, doc.page.width - 120, rowHeight)
.fillColor('#f2f2f2')
.fill()
.strokeColor('#dddddd')
.stroke();
doc.fillColor('#000000');
columns.forEach(col => {
doc.text(col.header, col.x, tableTop + 8, { width: col.width, align: 'left' });
});
let currentY = tableTop + rowHeight;
// Group expenses if needed
if (options.groupBy === 'none') {
currentY = await drawExpenseRows(doc, expenses, columns, currentY, rowHeight, fontSize, options);
} else {
const groups = groupExpenses(expenses, options.groupBy);
for (const [groupKey, groupExpenses] of Object.entries(groups)) {
// Check if we need a new page
if (currentY > doc.page.height - 100) {
doc.addPage();
currentY = 60;
}
// Group header
const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
doc.fontSize(fontSize + 1)
.font('Helvetica-Bold')
.fillColor('#000000');
doc.rect(60, currentY, doc.page.width - 120, rowHeight)
.fillColor('#e7f3ff')
.fill()
.strokeColor('#dddddd')
.stroke();
doc.fillColor('#000000')
.text(`${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})`,
65, currentY + 8, { width: doc.page.width - 130 });
currentY += rowHeight;
// Group expenses
currentY = await drawExpenseRows(doc, groupExpenses, columns, currentY, rowHeight, fontSize, options);
}
}
}
async function drawExpenseRows(
doc: PDFKit.PDFDocument,
expenses: Expense[],
columns: any[],
startY: number,
rowHeight: number,
fontSize: number,
options: PDFOptions
): Promise<number> {
let currentY = startY;
doc.fontSize(fontSize)
.font('Helvetica');
expenses.forEach((expense, index) => {
// Check if we need a new page
if (currentY > doc.page.height - 100) {
doc.addPage();
currentY = 60;
}
// Alternate row colors
if (index % 2 === 0) {
doc.rect(60, currentY, doc.page.width - 120, rowHeight)
.fillColor('#f9f9f9')
.fill();
}
doc.fillColor('#000000');
// Draw row data
const date = expense.Time ? formatDate(expense.Time) : 'N/A';
const establishment = expense['Establishment Name'] || 'N/A';
const category = expense.Category || 'N/A';
const payer = expense.Payer || 'N/A';
const amount = `${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}`;
const payment = expense['Payment Method'] || 'N/A';
const rowData = [date, establishment, category, payer, amount, payment];
if (options.includeReceiptContents) {
const description = expense.Contents || 'N/A';
rowData.push(description.length > 30 ? description.substring(0, 27) + '...' : description);
}
rowData.forEach((data, colIndex) => {
if (colIndex < columns.length) {
doc.text(data, columns[colIndex].x, currentY + 8, {
width: columns[colIndex].width - 5,
align: 'left',
ellipsis: true
});
}
});
currentY += rowHeight;
});
return currentY;
}
function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Expense[]> {
const groups: Record<string, Expense[]> = {};
expenses.forEach(expense => {
let groupKey = 'Unknown';
switch (groupBy) {
case 'payer':
groupKey = expense.Payer || 'Unknown Payer';
break;
case 'category':
groupKey = expense.Category || 'Unknown Category';
break;
case 'date':
groupKey = expense.Time ? formatDate(expense.Time) : 'Unknown Date';
break;
}
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(expense);
});
return groups;
}
async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
console.log('[expenses/generate-pdf] Adding receipt images...');
const expensesWithReceipts = expenses.filter(expense =>
expense.Receipt && Array.isArray(expense.Receipt) && expense.Receipt.length > 0
);
if (expensesWithReceipts.length === 0) {
console.log('[expenses/generate-pdf] No receipts found to include');
return;
}
// Add new page for receipts
doc.addPage();
doc.fontSize(18)
.font('Helvetica-Bold')
.text('Receipt Images', { align: 'center' });
doc.y += 20;
for (const expense of expensesWithReceipts) {
try {
// Add expense header
doc.fontSize(14)
.font('Helvetica-Bold')
.text(`Receipt for: ${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
{ align: 'left' });
doc.fontSize(12)
.font('Helvetica')
.text(`Date: ${expense.Time ? formatDate(expense.Time) : 'N/A'}`, { align: 'left' });
doc.y += 10;
// Process receipt images
if (expense.Receipt) {
for (const receipt of expense.Receipt) {
if (receipt.url || receipt.directus_files_id?.filename_download) {
try {
const imageBuffer = await fetchReceiptImage(receipt);
if (imageBuffer) {
// Check if we need a new page
if (doc.y > doc.page.height - 400) {
doc.addPage();
doc.y = 60;
}
// Add image
const maxWidth = 400;
const maxHeight = 300;
doc.image(imageBuffer, {
fit: [maxWidth, maxHeight],
align: 'center'
});
doc.y += 20;
}
} catch (imageError) {
console.error('[expenses/generate-pdf] Error adding receipt image:', imageError);
doc.fontSize(10)
.fillColor('#666666')
.text('Receipt image could not be loaded', { align: 'center' });
doc.fillColor('#000000');
doc.y += 10;
}
}
}
}
doc.y += 20;
} catch (error) {
console.error('[expenses/generate-pdf] Error processing receipt for expense:', expense.Id, error);
}
}
}
async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
try {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// Determine the file path
let filePath = receipt.url;
if (!filePath && receipt.directus_files_id?.filename_download) {
filePath = receipt.directus_files_id.filename_download;
}
if (!filePath) {
console.log('[expenses/generate-pdf] No file path found for receipt');
return null;
}
console.log('[expenses/generate-pdf] Fetching receipt image:', filePath);
// Get the object from MinIO
const dataStream = await client.getObject(bucketName, filePath);
// Convert stream to buffer
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
dataStream.on('data', (chunk) => chunks.push(chunk));
dataStream.on('end', () => resolve(Buffer.concat(chunks)));
dataStream.on('error', reject);
});
} catch (error) {
console.error('[expenses/generate-pdf] Error fetching receipt image:', error);
return null;
}
}
function addFooter(doc: PDFKit.PDFDocument) {
doc.fontSize(10)
.fillColor('#666666')
.text(`Generated on: ${new Date().toLocaleString()}`,
60, doc.page.height - 40, { align: 'right' });
}