888 lines
30 KiB
TypeScript
888 lines
30 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 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<Buffer> {
|
|
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<number> {
|
|
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<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 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' });
|
|
|
|
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 ${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' });
|
|
|
|
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 ${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' });
|
|
|
|
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 ${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<Buffer | null> {
|
|
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 {
|
|
// Fetch image directly from S3 URL
|
|
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}`);
|
|
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);
|
|
|
|
// If it's a timeout, try once more with a longer timeout
|
|
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError') {
|
|
console.log('[expenses/generate-pdf] Retrying with longer timeout...');
|
|
try {
|
|
const retryResponse = await fetch(rawPath, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'image/*'
|
|
},
|
|
signal: AbortSignal.timeout(60000) // 60 second timeout for retry
|
|
});
|
|
|
|
if (retryResponse.ok) {
|
|
const arrayBuffer = await retryResponse.arrayBuffer();
|
|
const imageBuffer = Buffer.from(arrayBuffer);
|
|
console.log('[expenses/generate-pdf] Successfully fetched image on retry, Size:', imageBuffer.length);
|
|
return imageBuffer;
|
|
}
|
|
} catch (retryError) {
|
|
console.error('[expenses/generate-pdf] Retry also failed:', retryError);
|
|
}
|
|
}
|
|
|
|
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<Buffer>((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' });
|
|
}
|