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 { 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 - adjusted for better layout const columns = [ { header: 'Date', width: 65, x: 60 }, { header: 'Establishment', width: 110, x: 125 }, { header: 'Category', width: 55, x: 235 }, { header: 'Payer', width: 50, x: 290 }, { header: 'Amount', width: 55, x: 340 }, { header: 'Payment', width: 45, x: 395 } ]; if (options.includeReceiptContents) { columns.push({ header: 'Description', width: 105, x: 440 }); } // 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 { 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 > 40 ? description.substring(0, 37) + '...' : 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 { const groups: Record = {}; 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...'); console.log('[expenses/generate-pdf] Total expenses to check:', expenses.length); // Log receipt data structure for debugging expenses.forEach((expense, index) => { console.log(`[expenses/generate-pdf] Expense ${index + 1} (ID: ${expense.Id}):`, { establishment: expense['Establishment Name'], hasReceipt: !!expense.Receipt, receiptType: typeof expense.Receipt, receiptLength: Array.isArray(expense.Receipt) ? expense.Receipt.length : 'N/A', receiptData: expense.Receipt }); }); const expensesWithReceipts = expenses.filter(expense => expense.Receipt && Array.isArray(expense.Receipt) && expense.Receipt.length > 0 ); console.log('[expenses/generate-pdf] Expenses with receipts:', expensesWithReceipts.length); if (expensesWithReceipts.length === 0) { console.log('[expenses/generate-pdf] No receipts found to include'); return; } let totalReceiptImages = 0; let processedImages = 0; // Count total receipt images for progress tracking expensesWithReceipts.forEach(expense => { if (expense.Receipt && Array.isArray(expense.Receipt)) { totalReceiptImages += expense.Receipt.length; } }); console.log('[expenses/generate-pdf] Total receipt images to process:', totalReceiptImages); for (const expense of expensesWithReceipts) { try { console.log('[expenses/generate-pdf] Processing receipts for expense:', expense.Id, expense['Establishment Name']); // Process receipt images - each gets its own page if (expense.Receipt && Array.isArray(expense.Receipt)) { for (const [receiptIndex, receipt] of expense.Receipt.entries()) { if (receipt.url || receipt.directus_files_id?.filename_download || receipt.filename_download) { try { console.log(`[expenses/generate-pdf] Fetching receipt ${receiptIndex + 1}/${expense.Receipt.length} for expense ${expense.Id}`); const imageBuffer = await fetchReceiptImage(receipt); if (imageBuffer) { // Add new page for each receipt image doc.addPage(); // Add header section for this receipt const headerHeight = 100; // Header background doc.rect(60, 60, doc.page.width - 120, headerHeight) .fillColor('#f8f9fa') .fill() .strokeColor('#dee2e6') .lineWidth(1) .stroke(); doc.fillColor('#000000'); // Receipt header content doc.fontSize(16) .font('Helvetica-Bold') .text(`Receipt Image ${receiptIndex + 1}${expense.Receipt.length > 1 ? ` of ${expense.Receipt.length}` : ''}`, 70, 80, { align: 'left' }); doc.fontSize(14) .font('Helvetica-Bold') .text(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`, 70, 105, { align: 'left' }); doc.fontSize(12) .font('Helvetica') .text(`Date: ${expense.Time ? formatDate(expense.Time) : 'N/A'}`, 70, 125, { align: 'left' }); doc.fontSize(10) .fillColor('#666666') .text(`Payer: ${expense.Payer || 'N/A'} | Category: ${expense.Category || 'N/A'}`, 70, 140, { align: 'left' }); doc.fillColor('#000000'); // Calculate available space for image (full page minus header and margins) const pageWidth = doc.page.width; const pageHeight = doc.page.height; const margin = 60; const imageStartY = 60 + headerHeight + 20; // Header + spacing const maxImageWidth = pageWidth - (margin * 2); const maxImageHeight = pageHeight - imageStartY - margin; console.log(`[expenses/generate-pdf] Adding large image - Max size: ${maxImageWidth}x${maxImageHeight}, Buffer size: ${imageBuffer.length} bytes`); // Add the receipt image with maximum size try { doc.image(imageBuffer, margin, imageStartY, { fit: [maxImageWidth, maxImageHeight], align: 'center', valign: 'center' }); processedImages++; console.log(`[expenses/generate-pdf] Successfully added receipt image ${processedImages}/${totalReceiptImages}`); } catch (imageEmbedError: any) { console.error('[expenses/generate-pdf] Error embedding image in PDF:', imageEmbedError); // Add error message on the page doc.fontSize(14) .fillColor('#dc3545') .text('Receipt image could not be embedded', margin, imageStartY + 50, { align: 'center', width: maxImageWidth }); doc.fontSize(12) .fillColor('#6c757d') .text(`Error: ${imageEmbedError.message || 'Unknown error'}`, margin, imageStartY + 80, { align: 'center', width: maxImageWidth }); doc.fillColor('#000000'); } } else { console.warn(`[expenses/generate-pdf] No image buffer received for receipt ${receiptIndex + 1} of expense ${expense.Id}`); // Add page with error message doc.addPage(); doc.fontSize(16) .font('Helvetica-Bold') .text(`Receipt Image ${receiptIndex + 1}${expense.Receipt.length > 1 ? ` of ${expense.Receipt.length}` : ''}`, { align: 'center' }); doc.fontSize(14) .font('Helvetica') .text(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`, { align: 'center' }); doc.y += 50; doc.fontSize(12) .fillColor('#dc3545') .text('Receipt image could not be loaded from storage', { align: 'center' }); doc.fillColor('#000000'); } } catch (imageError: any) { console.error(`[expenses/generate-pdf] Error processing receipt ${receiptIndex + 1} for expense ${expense.Id}:`, imageError); // Add page with error information doc.addPage(); doc.fontSize(16) .font('Helvetica-Bold') .text(`Receipt Image ${receiptIndex + 1}${expense.Receipt.length > 1 ? ` of ${expense.Receipt.length}` : ''}`, { align: 'center' }); doc.fontSize(14) .font('Helvetica') .text(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`, { align: 'center' }); doc.y += 50; doc.fontSize(12) .fillColor('#dc3545') .text('Error loading receipt image', { align: 'center' }); doc.fontSize(10) .fillColor('#6c757d') .text(`${imageError.message || 'Unknown error'}`, { align: 'center' }); doc.fillColor('#000000'); } } else { console.log(`[expenses/generate-pdf] Skipping receipt ${receiptIndex + 1} for expense ${expense.Id} - no valid file path`); } } } } catch (error) { console.error('[expenses/generate-pdf] Error processing receipts for expense:', expense.Id, error); } } console.log(`[expenses/generate-pdf] Completed processing ${processedImages}/${totalReceiptImages} receipt images`); } async function fetchReceiptImage(receipt: any): Promise { try { const client = getMinioClient(); const bucketName = useRuntimeConfig().minio.bucketName; // Determine the file path - try multiple possible locations let filePath = null; // Try different receipt data structures if (receipt.url) { filePath = receipt.url; } else if (receipt.directus_files_id?.filename_download) { filePath = receipt.directus_files_id.filename_download; } else if (receipt.filename_download) { filePath = receipt.filename_download; } else if (receipt.id && receipt.filename_disk) { filePath = receipt.filename_disk; } else if (typeof receipt === 'string') { filePath = receipt; } if (!filePath) { console.log('[expenses/generate-pdf] No file path found for receipt:', JSON.stringify(receipt, null, 2)); return null; } console.log('[expenses/generate-pdf] Fetching receipt image from path:', filePath); // Remove any URL prefixes if present if (filePath.includes('/files/')) { const parts = filePath.split('/files/'); if (parts.length > 1) { filePath = parts[parts.length - 1]; } } // Ensure we're looking in the right place - sometimes files are in receipts/ folder const possiblePaths = [ filePath, `receipts/${filePath}`, `expenses/${filePath}`, filePath.startsWith('receipts/') ? filePath : `receipts/${filePath}` ]; for (const testPath of possiblePaths) { try { console.log('[expenses/generate-pdf] Trying path:', testPath); // Check if object exists first await client.statObject(bucketName, testPath); // Get the object from MinIO const dataStream = await client.getObject(bucketName, testPath); // Convert stream to buffer const chunks: Buffer[] = []; const imageBuffer = await new Promise((resolve, reject) => { dataStream.on('data', (chunk) => chunks.push(chunk)); dataStream.on('end', () => resolve(Buffer.concat(chunks))); dataStream.on('error', reject); }); console.log('[expenses/generate-pdf] Successfully fetched image from:', testPath, 'Size:', imageBuffer.length); return imageBuffer; } catch (pathError) { console.log('[expenses/generate-pdf] Path not found:', testPath); continue; } } console.log('[expenses/generate-pdf] Could not find image in any of the attempted paths'); return null; } 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' }); }