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

508 lines
15 KiB
TypeScript

import { requireAuth } from '@/server/utils/auth';
import { getExpenseById } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency';
import { uploadBuffer } from '@/server/utils/minio';
import { generate } from '@pdfme/generator';
import { Template } from '@pdfme/common';
import sharp from 'sharp';
import type { Expense } from '@/utils/types';
interface PDFOptions {
documentName: string;
subheader?: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeSummary: boolean;
includeDetails: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
includeProcessingFee?: boolean;
}
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] Generating PDF for expenses:', expenseIds);
try {
// Get user info for file naming
const userInfo = event.context.user;
const userName = userInfo?.preferred_username || userInfo?.email || 'user';
const userEmail = userInfo?.email;
// Determine if we should use direct generation or email delivery
const shouldEmailDelivery = expenseIds.length >= 20;
if (shouldEmailDelivery && !userEmail) {
throw createError({
statusCode: 400,
statusMessage: 'Email address is required for large PDF generation'
});
}
if (shouldEmailDelivery) {
// Queue for background processing
setResponseStatus(event, 202); // Accepted
// Start background processing (simplified for now)
// In a real implementation, you'd use a proper queue system
process.nextTick(async () => {
try {
await generatePDFBackground(expenseIds, options, userName, userEmail);
} catch (error) {
console.error('[expenses/generate-pdf] Background generation failed:', error);
// TODO: Send error email to user
}
});
return {
message: "Your PDF is being generated and will be emailed to you shortly.",
estimatedTime: `${Math.ceil(expenseIds.length / 10)} minutes`,
deliveryMethod: 'email'
};
}
// Direct generation for smaller requests
const pdfBuffer = await generatePDFDirect(expenseIds, options, userName);
// Generate filename with date range
const dates = await getExpenseDates(expenseIds);
const filename = generateFilename(userName, dates, options.documentName);
// Store PDF in MinIO
const year = new Date().getFullYear();
const month = String(new Date().getMonth() + 1).padStart(2, '0');
const storagePath = `expense-sheets/${year}/${month}/${filename}`;
try {
await uploadBuffer(pdfBuffer, storagePath, 'application/pdf');
console.log(`[expenses/generate-pdf] PDF stored at: ${storagePath}`);
} catch (error) {
console.error('[expenses/generate-pdf] Failed to store PDF in MinIO:', error);
// Continue with direct download even if storage fails
}
// Return PDF for direct download
setHeader(event, 'Content-Type', 'application/pdf');
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`);
setHeader(event, 'Content-Length', pdfBuffer.length.toString());
return pdfBuffer;
} catch (error: any) {
console.error('[expenses/generate-pdf] Error generating PDF:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to generate PDF'
});
}
});
async function generatePDFDirect(expenseIds: number[], options: PDFOptions, userName: string): Promise<Buffer> {
// Fetch all expenses
const expenses: Expense[] = [];
const failedExpenses: string[] = [];
for (const id of expenseIds) {
try {
const expense = await getExpenseById(id.toString());
const processedExpense = await processExpenseWithCurrency(expense);
expenses.push(processedExpense);
} catch (error) {
console.error(`[expenses/generate-pdf] Failed to fetch expense ${id}:`, error);
failedExpenses.push(id.toString());
}
}
if (failedExpenses.length > 0) {
throw new Error(`Failed to fetch expenses: ${failedExpenses.join(', ')}`);
}
if (expenses.length === 0) {
throw new Error('No expenses found');
}
// Validate receipt images if required
if (options.includeReceipts) {
await validateReceiptImages(expenses);
}
// Sort expenses by date
expenses.sort((a, b) => new Date(a.Time).getTime() - new Date(b.Time).getTime());
// Group expenses if needed
const groupedExpenses = groupExpenses(expenses, options.groupBy);
// Calculate totals
const { subtotalEUR, processingFee, totalWithFee } = calculateTotals(expenses);
// Generate PDF
const template = await createPDFTemplate(options, groupedExpenses, {
subtotalEUR,
processingFee,
totalWithFee,
userName,
includeProcessingFee: options.includeProcessingFee ?? true
});
const inputs = await createPDFInputs(groupedExpenses, options);
const pdf = await generate({
template,
inputs
});
return Buffer.from(pdf);
}
async function generatePDFBackground(expenseIds: number[], options: PDFOptions, userName: string, userEmail: string) {
try {
console.log('[expenses/generate-pdf] Starting background PDF generation');
const pdfBuffer = await generatePDFDirect(expenseIds, options, userName);
// Generate filename and store in MinIO
const dates = await getExpenseDates(expenseIds);
const filename = generateFilename(userName, dates, options.documentName);
const year = new Date().getFullYear();
const month = String(new Date().getMonth() + 1).padStart(2, '0');
const storagePath = `expense-sheets/${year}/${month}/${filename}`;
await uploadBuffer(pdfBuffer, storagePath, 'application/pdf');
// TODO: Send email with download link
console.log(`[expenses/generate-pdf] Background PDF generated and stored at: ${storagePath}`);
// For now, just log success - in a real implementation, you'd send an email
// await sendPDFReadyEmail(userEmail, filename, storagePath);
} catch (error) {
console.error('[expenses/generate-pdf] Background generation failed:', error);
throw error;
}
}
async function validateReceiptImages(expenses: Expense[]) {
const missingImages: string[] = [];
for (const expense of expenses) {
if (expense.Receipt && expense.Receipt.length > 0) {
for (const [index, receipt] of expense.Receipt.entries()) {
if (!receipt.signedUrl && !receipt.url) {
missingImages.push(`Expense #${expense.Id} (${expense['Establishment Name']} on ${expense.Time}) - Receipt ${index + 1}`);
}
}
}
}
if (missingImages.length > 0) {
throw new Error(`Missing receipt images:\n${missingImages.join('\n')}`);
}
}
function groupExpenses(expenses: Expense[], groupBy: PDFOptions['groupBy']) {
if (groupBy === 'none') {
return [{ title: 'All Expenses', expenses }];
}
const groups: Record<string, Expense[]> = {};
expenses.forEach(expense => {
let key: string;
switch (groupBy) {
case 'payer':
key = expense.Payer || 'Unknown';
break;
case 'category':
key = expense.Category || 'Other';
break;
case 'date':
key = new Date(expense.Time).toLocaleDateString();
break;
default:
key = 'All';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(expense);
});
return Object.entries(groups).map(([title, expenses]) => ({
title,
expenses
}));
}
function calculateTotals(expenses: Expense[]) {
const subtotalEUR = expenses.reduce((sum, expense) => {
if (expense.currency === 'EUR') {
return sum + (expense.PriceNumber || 0);
} else {
// Convert to EUR
return sum + (expense.PriceNumber || 0) / (expense.ConversionRate || 1);
}
}, 0);
const processingFee = subtotalEUR * 0.05;
const totalWithFee = subtotalEUR + processingFee;
return { subtotalEUR, processingFee, totalWithFee };
}
async function createPDFTemplate(
options: PDFOptions,
groupedExpenses: Array<{ title: string; expenses: Expense[] }>,
totals: any
): Promise<Template> {
// Get page dimensions
const pageDimensions = getPageDimensions(options.pageFormat);
// Create template structure
const template: Template = {
basePdf: null, // We'll create from scratch
schemas: []
};
// Add cover page
template.schemas.push(createCoverPageSchema(options, totals, pageDimensions));
// Add expense pages (one per expense)
let pageIndex = 1;
for (const group of groupedExpenses) {
for (const expense of group.expenses) {
template.schemas.push(
await createExpensePageSchema(expense, pageIndex, group.expenses.length, options, pageDimensions)
);
pageIndex++;
}
}
// Add summary page if requested
if (options.includeSummary) {
template.schemas.push(createSummaryPageSchema(groupedExpenses, totals, options, pageDimensions));
}
return template;
}
async function createPDFInputs(
groupedExpenses: Array<{ title: string; expenses: Expense[] }>,
options: PDFOptions
): Promise<any[]> {
const inputs: any[] = [];
// Cover page input
inputs.push({
title: options.documentName,
subheader: options.subheader || '',
date: new Date().toLocaleDateString(),
// Add other cover page data
});
// Expense page inputs
for (const group of groupedExpenses) {
for (const expense of group.expenses) {
const input: any = {
establishment: expense['Establishment Name'] || '',
amount: formatCurrency(expense),
category: expense.Category || '',
payer: expense.Payer || '',
date: new Date(expense.Time).toLocaleDateString(),
time: new Date(expense.Time).toLocaleTimeString(),
description: expense.Contents || '',
paymentMethod: expense['Payment Method'] || ''
};
// Add receipt images if required
if (options.includeReceipts && expense.Receipt && expense.Receipt.length > 0) {
input.receiptImages = await Promise.all(
expense.Receipt.map(async (receipt) => {
try {
const imageBuffer = await fetchReceiptImage(receipt.signedUrl || receipt.url);
return await optimizeImageForPDF(imageBuffer);
} catch (error) {
throw new Error(`Failed to load receipt image for expense #${expense.Id}: ${error}`);
}
})
);
}
inputs.push(input);
}
}
return inputs;
}
function createCoverPageSchema(options: PDFOptions, totals: any, dimensions: any) {
return {
title: {
type: 'text',
position: { x: 50, y: 100 },
width: dimensions.width - 100,
height: 50,
fontSize: 24,
fontName: 'Helvetica-Bold',
alignment: 'center'
},
subheader: {
type: 'text',
position: { x: 50, y: 160 },
width: dimensions.width - 100,
height: 30,
fontSize: 14,
fontName: 'Helvetica',
alignment: 'center'
},
// Add more fields as needed
};
}
async function createExpensePageSchema(
expense: Expense,
pageIndex: number,
totalPages: number,
options: PDFOptions,
dimensions: any
) {
const schema: any = {
// Header
pageNumber: {
type: 'text',
position: { x: dimensions.width - 100, y: 20 },
width: 80,
height: 20,
fontSize: 10,
fontName: 'Helvetica',
alignment: 'right'
},
// Expense details
establishment: {
type: 'text',
position: { x: 50, y: 80 },
width: dimensions.width - 100,
height: 30,
fontSize: 16,
fontName: 'Helvetica-Bold'
},
amount: {
type: 'text',
position: { x: 50, y: 120 },
width: 200,
height: 25,
fontSize: 14,
fontName: 'Helvetica'
},
// Add more fields as needed
};
// Add receipt image if available
if (options.includeReceipts && expense.Receipt && expense.Receipt.length > 0) {
schema.receiptImage = {
type: 'image',
position: { x: 50, y: 200 },
width: dimensions.width - 100,
height: 400
};
}
return schema;
}
function createSummaryPageSchema(groupedExpenses: any, totals: any, options: PDFOptions, dimensions: any) {
return {
summaryTitle: {
type: 'text',
position: { x: 50, y: 50 },
width: dimensions.width - 100,
height: 30,
fontSize: 18,
fontName: 'Helvetica-Bold',
alignment: 'center'
},
// Add summary fields
};
}
function getPageDimensions(format: PDFOptions['pageFormat']) {
switch (format) {
case 'A4':
return { width: 595, height: 842 };
case 'Letter':
return { width: 612, height: 792 };
case 'Legal':
return { width: 612, height: 1008 };
default:
return { width: 595, height: 842 };
}
}
function formatCurrency(expense: Expense): string {
if (expense.currency === 'EUR') {
return `${(expense.PriceNumber || 0).toFixed(2)}`;
} else {
const eurAmount = (expense.PriceNumber || 0) / (expense.ConversionRate || 1);
return `${eurAmount.toFixed(2)} (${expense.CurrencySymbol || '$'}${(expense.PriceNumber || 0).toFixed(2)} ${expense.currency} original)`;
}
}
async function fetchReceiptImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
async function optimizeImageForPDF(imageBuffer: Buffer): Promise<string> {
// Optimize image for PDF embedding
const optimizedBuffer = await sharp(imageBuffer)
.resize(800, 600, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
// Convert to base64 for PDF embedding
return `data:image/jpeg;base64,${optimizedBuffer.toString('base64')}`;
}
async function getExpenseDates(expenseIds: number[]): Promise<{ start: Date; end: Date }> {
// This is a simplified version - in practice, you'd fetch the dates
const now = new Date();
return {
start: new Date(now.getFullYear(), now.getMonth(), 1),
end: now
};
}
function generateFilename(userName: string, dates: { start: Date; end: Date }, documentName: string): string {
const formatDate = (date: Date) => date.toISOString().split('T')[0];
const sanitizedDocName = documentName.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, '-');
return `expense-report_${userName}_${formatDate(dates.start)}_to_${formatDate(dates.end)}_${sanitizedDocName}.pdf`;
}