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; targetCurrency?: 'USD' | 'EUR'; } interface Expense { Id: number; 'Establishment Name': string; Price: string; PriceNumber: number; Currency?: string; CurrencySymbol?: string; DisplayPrice: string; DisplayPriceWithEUR?: string; PriceEUR?: number; 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 with target currency processing const targetCurrency = options.targetCurrency || 'EUR'; const expenses: Expense[] = []; for (const expenseId of expenseIds) { const expense = await getExpenseById(expenseId); if (expense) { const processedExpense = await processExpenseWithCurrency(expense, targetCurrency); expenses.push(processedExpense); } } if (expenses.length === 0) { throw createError({ statusCode: 404, statusMessage: 'No valid expenses found' }); } // Calculate totals const totals = calculateTotals(expenses, options.includeProcessingFee, targetCurrency); 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, targetCurrency: string = 'EUR') { // Calculate target currency total const targetTotal = expenses.reduce((sum, exp) => { if (targetCurrency.toUpperCase() === 'USD') { return sum + (exp.PriceUSD || exp.PriceNumber || 0); } else { return sum + (exp.PriceEUR || exp.PriceNumber || 0); } }, 0); // Calculate EUR total for compatibility const eurTotal = expenses.reduce((sum, exp) => sum + (exp.PriceEUR || exp.PriceNumber || 0), 0); // Calculate USD total for compatibility const usdTotal = expenses.reduce((sum, exp) => sum + (exp.PriceUSD || exp.PriceNumber || 0), 0); // Processing fee is calculated on target currency total const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0; const finalTotal = targetTotal + processingFee; return { targetTotal, eurTotal, usdTotal, processingFee, finalTotal, targetCurrency: targetCurrency.toUpperCase(), 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 targetCurrency = totals.targetCurrency || 'EUR'; const targetSymbol = targetCurrency === 'USD' ? '$' : '€'; doc.text(`Total Expenses:`, leftX, doc.y, { continued: true }) .font('Helvetica-Bold') .text(` ${totals.count}`, { align: 'left' }); doc.font('Helvetica') .text(`Subtotal (${targetCurrency}):`, leftX, doc.y + 5, { continued: true }) .font('Helvetica-Bold') .text(` ${targetSymbol}${totals.targetTotal.toFixed(2)}`, { align: 'left' }); // Show the other currency as reference if (targetCurrency === 'USD') { doc.font('Helvetica') .text(`EUR Equivalent:`, leftX, doc.y + 5, { continued: true }) .font('Helvetica-Bold') .text(` €${totals.eurTotal.toFixed(2)}`, { align: 'left' }); } else { 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(` ${targetSymbol}${totals.processingFee.toFixed(2)}`, { align: 'left' }); } doc.font('Helvetica') .text(`Final Total:`, leftX, doc.y + 5, { continued: true }) .font('Helvetica-Bold') .fontSize(14) .text(` ${targetSymbol}${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 - show EUR total const groupEurTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceEUR || 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 - €${groupEurTotal.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 - show original amount with EUR conversion 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'; // Display amount with EUR conversion if needed let amount; if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) { const symbol = expense.CurrencySymbol || expense.Currency; amount = `${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`; } else { 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; let currentReceiptNumber = 0; // Track overall receipt number across all expenses // 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()) { currentReceiptNumber++; // Increment overall receipt counter if (receipt.url || receipt.signedUrl || receipt.directus_files_id?.filename_download || receipt.filename_download) { try { console.log(`[expenses/generate-pdf] Fetching receipt ${currentReceiptNumber}/${totalReceiptImages} (expense ${expense.Id}, receipt ${receiptIndex + 1}/${expense.Receipt.length})`); 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 with overall numbering doc.fontSize(16) .font('Helvetica-Bold') .text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`, 70, 80, { align: 'left' }); // Show amount with EUR conversion let amountText; if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) { const symbol = expense.CurrencySymbol || expense.Currency; amountText = `${expense['Establishment Name']} - ${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`; } else { amountText = `${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`; } doc.fontSize(14) .font('Helvetica-Bold') .text(amountText, 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 ${currentReceiptNumber} of expense ${expense.Id}`); // Add page with error message doc.addPage(); doc.fontSize(16) .font('Helvetica-Bold') .text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`, { align: 'center' }); // Show amount with EUR conversion let centerAmountText; if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) { const symbol = expense.CurrencySymbol || expense.Currency; centerAmountText = `${expense['Establishment Name']} - ${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`; } else { centerAmountText = `${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`; } doc.fontSize(14) .font('Helvetica') .text(centerAmountText, { 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 ${currentReceiptNumber} for expense ${expense.Id}:`, imageError); // Add page with error information doc.addPage(); doc.fontSize(16) .font('Helvetica-Bold') .text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`, { align: 'center' }); // Show amount with EUR conversion let errorAmountText; if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) { const symbol = expense.CurrencySymbol || expense.Currency; errorAmountText = `${expense['Establishment Name']} - ${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`; } else { errorAmountText = `${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`; } doc.fontSize(14) .font('Helvetica') .text(errorAmountText, { 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 ${currentReceiptNumber} 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 { // Determine the file path - try multiple possible sources let rawPath = null; // Try different receipt data structures - prioritize signedUrl for S3 URLs if (receipt.signedUrl) { rawPath = receipt.signedUrl; } else if (receipt.url) { rawPath = receipt.url; } else if (receipt.directus_files_id?.filename_download) { rawPath = receipt.directus_files_id.filename_download; } else if (receipt.filename_download) { rawPath = receipt.filename_download; } else if (receipt.id && receipt.filename_disk) { rawPath = receipt.filename_disk; } else if (typeof receipt === 'string') { rawPath = receipt; } if (!rawPath) { console.log('[expenses/generate-pdf] No file path found for receipt:', JSON.stringify(receipt, null, 2)); return null; } console.log('[expenses/generate-pdf] Raw path from receipt:', rawPath); // Check if this is an S3 URL (HTTP/HTTPS) if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) { console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...'); try { // Use the signed URL directly without modification to preserve AWS signature console.log('[expenses/generate-pdf] Fetching from S3 URL (preserving signature):', rawPath); // Fetch image directly from S3 URL with minimal headers to avoid signature issues const response = await fetch(rawPath, { method: 'GET', headers: { 'Accept': 'image/*' }, // Add timeout to prevent hanging signal: AbortSignal.timeout(30000) // 30 second timeout }); if (!response.ok) { console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`); console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries())); return null; } // Convert response to buffer const arrayBuffer = await response.arrayBuffer(); const imageBuffer = Buffer.from(arrayBuffer); console.log('[expenses/generate-pdf] Successfully fetched image from S3 URL, Size:', imageBuffer.length); return imageBuffer; } catch (fetchError: any) { console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message); console.error('[expenses/generate-pdf] Error details:', { name: fetchError.name, code: fetchError.code, message: fetchError.message }); // Don't try multiple attempts for signed URLs as they may expire return null; } } // If not an S3 URL, try MinIO as fallback console.log('[expenses/generate-pdf] Not an S3 URL, trying MinIO fallback...'); const client = getMinioClient(); const bucketName = useRuntimeConfig().minio.bucketName; // Extract MinIO path from the raw path let minioPath = extractMinioPath(rawPath); if (!minioPath) { console.log('[expenses/generate-pdf] Could not extract MinIO path from:', rawPath); return null; } console.log('[expenses/generate-pdf] Extracted MinIO path:', minioPath); // Try multiple possible locations in MinIO const possiblePaths = [ minioPath, `receipts/${minioPath}`, `expenses/${minioPath}`, // Try without any folder prefix minioPath.split('/').pop() || minioPath, // Try in receipts folder with just filename `receipts/${minioPath.split('/').pop() || minioPath}`, // Try in expenses folder with just filename `expenses/${minioPath.split('/').pop() || minioPath}` ]; // Remove duplicates const uniquePaths = [...new Set(possiblePaths)]; for (const testPath of uniquePaths) { try { console.log('[expenses/generate-pdf] Trying MinIO 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 MinIO path:', testPath, 'Size:', imageBuffer.length); return imageBuffer; } catch (pathError) { console.log('[expenses/generate-pdf] MinIO path not found:', testPath); continue; } } console.log('[expenses/generate-pdf] Could not find image in any of the attempted MinIO paths:', uniquePaths); return null; } catch (error) { console.error('[expenses/generate-pdf] Error fetching receipt image:', error); return null; } } /** * Extract the MinIO path from an S3 URL or return the path as-is */ function extractMinioPath(urlOrPath: string): string | null { try { // If it's already just a path, return it if (!urlOrPath.startsWith('http')) { return urlOrPath; } // Parse the URL const url = new URL(urlOrPath); // Extract the pathname (removes query parameters) let pathname = decodeURIComponent(url.pathname); console.log('[expenses/generate-pdf] URL pathname:', pathname); // For S3 URLs, we need to extract the part after the bucket name // Pattern: /database/nc/uploads/path/to/file.jpg // We want: uploads/path/to/file.jpg // Remove leading slash if (pathname.startsWith('/')) { pathname = pathname.substring(1); } // Look for common patterns if (pathname.includes('/uploads/')) { // Extract everything from 'uploads/' onwards const uploadsIndex = pathname.indexOf('uploads/'); const extractedPath = pathname.substring(uploadsIndex); console.log('[expenses/generate-pdf] Extracted path from uploads pattern:', extractedPath); return extractedPath; } if (pathname.includes('/nc/uploads/')) { // Extract everything from 'uploads/' onwards const uploadsIndex = pathname.indexOf('uploads/'); const extractedPath = pathname.substring(uploadsIndex); console.log('[expenses/generate-pdf] Extracted path from nc/uploads pattern:', extractedPath); return extractedPath; } if (pathname.includes('/database/')) { // Remove the database prefix const databaseIndex = pathname.indexOf('database/'); const withoutDatabase = pathname.substring(databaseIndex + 'database/'.length); console.log('[expenses/generate-pdf] Extracted path after removing database prefix:', withoutDatabase); return withoutDatabase; } // If no specific pattern found, return the pathname as-is console.log('[expenses/generate-pdf] Using pathname as-is:', pathname); return pathname; } catch (error) { console.error('[expenses/generate-pdf] Error parsing URL:', error); // If URL parsing fails, try to extract manually // Remove query parameters manually const withoutQuery = urlOrPath.split('?')[0]; // Look for uploads pattern if (withoutQuery.includes('/uploads/')) { const uploadsIndex = withoutQuery.indexOf('/uploads/'); return withoutQuery.substring(uploadsIndex + 1); // +1 to remove the leading slash } 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' }); }