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 puppeteer from 'puppeteer'; 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 HTML content const htmlContent = generateHTMLContent(expenses, options, totals); // Convert HTML to PDF using Puppeteer const pdfBuffer = await generatePDFFromHTML(htmlContent, options); // 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) { // If it's our intentional error, re-throw it if (error.statusCode === 501) { throw error; } 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) { 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 generateHTMLContent(expenses: Expense[], options: PDFOptions, totals: any): string { // Generate HTML content that can be converted to PDF const html = ` ${options.documentName}
${options.documentName}
${options.subheader ? `
${options.subheader}
` : ''}
${options.includeSummary ? `

Summary

Total Expenses: ${totals.count}

Subtotal: €${totals.originalTotal.toFixed(2)}

USD Equivalent: $${totals.usdTotal.toFixed(2)}

${options.includeProcessingFee ? `

Processing Fee (5%): €${totals.processingFee.toFixed(2)}

` : ''}

Final Total: €${totals.finalTotal.toFixed(2)}

Grouping: ${getGroupingLabel(options.groupBy)}

` : ''} ${options.includeDetails ? generateExpenseTable(expenses, options) : ''}
Generated on: ${new Date().toLocaleString()}
`; return html; } function generateExpenseTable(expenses: Expense[], options: PDFOptions): string { let tableHTML = ` ${options.includeReceiptContents ? '' : ''} `; if (options.groupBy === 'none') { // No grouping - just list all expenses expenses.forEach(expense => { tableHTML += generateExpenseRow(expense, options); }); } else { // Group expenses const groups = groupExpenses(expenses, options.groupBy); Object.keys(groups).forEach(groupKey => { const groupExpenses = groups[groupKey]; const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0); // Group header tableHTML += ` `; // Group expenses groupExpenses.forEach(expense => { tableHTML += generateExpenseRow(expense, options); }); }); } tableHTML += `
Date Establishment Category Payer Amount Payment MethodDescription
${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})
`; return tableHTML; } function generateExpenseRow(expense: Expense, options: PDFOptions): string { const date = expense.Time ? formatDate(expense.Time) : 'N/A'; const description = expense.Contents || 'N/A'; return ` ${date} ${expense['Establishment Name'] || 'N/A'} ${expense.Category || 'N/A'} ${expense.Payer || 'N/A'} €${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'} ${expense['Payment Method'] || 'N/A'} ${options.includeReceiptContents ? `${description}` : ''} `; } 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 generatePDFFromHTML(htmlContent: string, options: PDFOptions): Promise { let browser; try { console.log('[expenses/generate-pdf] Launching Puppeteer browser...'); // Launch browser with optimized settings browser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--no-zygote', '--disable-gpu' ] }); const page = await browser.newPage(); // Set content with proper encoding await page.setContent(htmlContent, { waitUntil: 'networkidle0', timeout: 30000 }); // Get page format dimensions const format = getPageFormat(options.pageFormat); console.log('[expenses/generate-pdf] Generating PDF with format:', format); // Generate PDF with proper options const pdfUint8Array = await page.pdf({ format: format.format, printBackground: true, margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }, preferCSSPageSize: true }); // Convert Uint8Array to Buffer const pdfBuffer = Buffer.from(pdfUint8Array); console.log('[expenses/generate-pdf] PDF generated successfully, size:', pdfBuffer.length, 'bytes'); return pdfBuffer; } catch (error: any) { console.error('[expenses/generate-pdf] Puppeteer error:', error); throw new Error(`PDF generation failed: ${error?.message || 'Unknown error'}`); } finally { if (browser) { await browser.close(); } } } function getPageFormat(pageFormat: string): { format: any } { switch (pageFormat) { case 'Letter': return { format: 'letter' }; case 'Legal': return { format: 'legal' }; case 'A4': default: return { format: 'a4' }; } }