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

364 lines
11 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 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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${options.documentName}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.header { text-align: center; margin-bottom: 30px; border-bottom: 2px solid #333; padding-bottom: 20px; }
.document-title { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
.subheader { font-size: 16px; color: #666; }
.summary { background-color: #f5f5f5; padding: 15px; margin: 20px 0; border-radius: 5px; }
.expense-table { width: 100%; border-collapse: collapse; margin-top: 20px; }
.expense-table th, .expense-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
.expense-table th { background-color: #f2f2f2; font-weight: bold; }
.expense-table tr:nth-child(even) { background-color: #f9f9f9; }
.group-header { background-color: #e7f3ff; font-weight: bold; }
.total-row { background-color: #d4edda; font-weight: bold; }
.processing-fee { background-color: #fff3cd; }
.final-total { background-color: #d1ecf1; font-weight: bold; font-size: 1.1em; }
.date-generated { text-align: right; color: #666; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<div class="document-title">${options.documentName}</div>
${options.subheader ? `<div class="subheader">${options.subheader}</div>` : ''}
</div>
${options.includeSummary ? `
<div class="summary">
<h3>Summary</h3>
<p><strong>Total Expenses:</strong> ${totals.count}</p>
<p><strong>Subtotal:</strong> €${totals.originalTotal.toFixed(2)}</p>
<p><strong>USD Equivalent:</strong> $${totals.usdTotal.toFixed(2)}</p>
${options.includeProcessingFee ? `<p><strong>Processing Fee (5%):</strong> €${totals.processingFee.toFixed(2)}</p>` : ''}
<p><strong>Final Total:</strong> €${totals.finalTotal.toFixed(2)}</p>
<p><strong>Grouping:</strong> ${getGroupingLabel(options.groupBy)}</p>
</div>
` : ''}
${options.includeDetails ? generateExpenseTable(expenses, options) : ''}
<div class="date-generated">
Generated on: ${new Date().toLocaleString()}
</div>
</body>
</html>`;
return html;
}
function generateExpenseTable(expenses: Expense[], options: PDFOptions): string {
let tableHTML = `
<table class="expense-table">
<thead>
<tr>
<th>Date</th>
<th>Establishment</th>
<th>Category</th>
<th>Payer</th>
<th>Amount</th>
<th>Payment Method</th>
${options.includeReceiptContents ? '<th>Description</th>' : ''}
</tr>
</thead>
<tbody>
`;
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 += `
<tr class="group-header">
<td colspan="${options.includeReceiptContents ? '7' : '6'}">${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})</td>
</tr>
`;
// Group expenses
groupExpenses.forEach(expense => {
tableHTML += generateExpenseRow(expense, options);
});
});
}
tableHTML += `
</tbody>
</table>
`;
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 `
<tr>
<td>${date}</td>
<td>${expense['Establishment Name'] || 'N/A'}</td>
<td>${expense.Category || 'N/A'}</td>
<td>${expense.Payer || 'N/A'}</td>
<td>€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}</td>
<td>${expense['Payment Method'] || 'N/A'}</td>
${options.includeReceiptContents ? `<td>${description}</td>` : ''}
</tr>
`;
}
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 generatePDFFromHTML(htmlContent: string, options: PDFOptions): Promise<Buffer> {
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' };
}
}