feat: Add currency selection and conversion support in PDF generation, enhance expense processing with target currency handling
This commit is contained in:
@@ -16,6 +16,7 @@ interface PDFOptions {
|
||||
includeDetails: boolean;
|
||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||
includeProcessingFee?: boolean;
|
||||
targetCurrency?: 'USD' | 'EUR';
|
||||
}
|
||||
|
||||
interface Expense {
|
||||
@@ -23,7 +24,11 @@ interface Expense {
|
||||
'Establishment Name': string;
|
||||
Price: string;
|
||||
PriceNumber: number;
|
||||
Currency?: string;
|
||||
CurrencySymbol?: string;
|
||||
DisplayPrice: string;
|
||||
DisplayPriceWithEUR?: string;
|
||||
PriceEUR?: number;
|
||||
PriceUSD?: number;
|
||||
ConversionRate?: number;
|
||||
Payer: string;
|
||||
@@ -57,12 +62,13 @@ export default defineEventHandler(async (event) => {
|
||||
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
|
||||
|
||||
try {
|
||||
// Fetch expense data
|
||||
// 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);
|
||||
const processedExpense = await processExpenseWithCurrency(expense, targetCurrency);
|
||||
expenses.push(processedExpense);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +81,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totals = calculateTotals(expenses, options.includeProcessingFee);
|
||||
const totals = calculateTotals(expenses, options.includeProcessingFee, targetCurrency);
|
||||
|
||||
console.log('[expenses/generate-pdf] Successfully calculated totals:', totals);
|
||||
console.log('[expenses/generate-pdf] Options received:', options);
|
||||
@@ -105,18 +111,33 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false) {
|
||||
const originalTotal = expenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
||||
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);
|
||||
|
||||
const processingFee = includeProcessingFee ? originalTotal * 0.05 : 0;
|
||||
const finalTotal = originalTotal + processingFee;
|
||||
// Processing fee is calculated on target currency total
|
||||
const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0;
|
||||
const finalTotal = targetTotal + processingFee;
|
||||
|
||||
return {
|
||||
originalTotal,
|
||||
targetTotal,
|
||||
eurTotal,
|
||||
usdTotal,
|
||||
processingFee,
|
||||
finalTotal,
|
||||
targetCurrency: targetCurrency.toUpperCase(),
|
||||
count: expenses.length
|
||||
};
|
||||
}
|
||||
@@ -242,34 +263,43 @@ function addSummary(doc: PDFKit.PDFDocument, totals: any, options: PDFOptions) {
|
||||
.font('Helvetica');
|
||||
|
||||
const leftX = 80;
|
||||
const rightX = doc.page.width - 200;
|
||||
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:`, leftX, doc.y + 5, { continued: true })
|
||||
.text(`Subtotal (${targetCurrency}):`, leftX, doc.y + 5, { continued: true })
|
||||
.font('Helvetica-Bold')
|
||||
.text(` €${totals.originalTotal.toFixed(2)}`, { align: 'left' });
|
||||
.text(` ${targetSymbol}${totals.targetTotal.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' });
|
||||
// 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(` €${totals.processingFee.toFixed(2)}`, { align: 'left' });
|
||||
.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(` €${totals.finalTotal.toFixed(2)}`, { align: 'left' });
|
||||
.text(` ${targetSymbol}${totals.finalTotal.toFixed(2)}`, { align: 'left' });
|
||||
|
||||
doc.fontSize(12)
|
||||
.font('Helvetica')
|
||||
@@ -338,8 +368,8 @@ async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], opt
|
||||
currentY = 60;
|
||||
}
|
||||
|
||||
// Group header
|
||||
const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
||||
// 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');
|
||||
@@ -351,7 +381,7 @@ async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], opt
|
||||
.stroke();
|
||||
|
||||
doc.fillColor('#000000')
|
||||
.text(`${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})`,
|
||||
.text(`${groupKey} (${groupExpenses.length} expenses - €${groupEurTotal.toFixed(2)})`,
|
||||
65, currentY + 8, { width: doc.page.width - 130 });
|
||||
|
||||
currentY += rowHeight;
|
||||
@@ -392,12 +422,21 @@ async function drawExpenseRows(
|
||||
|
||||
doc.fillColor('#000000');
|
||||
|
||||
// Draw row data
|
||||
// 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';
|
||||
const amount = `€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}`;
|
||||
|
||||
// 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];
|
||||
@@ -526,10 +565,18 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||
.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(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
||||
70, 105, { align: 'left' });
|
||||
.text(amountText, 70, 105, { align: 'left' });
|
||||
|
||||
doc.fontSize(12)
|
||||
.font('Helvetica')
|
||||
@@ -597,10 +644,18 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||
.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(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
||||
{ align: 'center' });
|
||||
.text(centerAmountText, { align: 'center' });
|
||||
|
||||
doc.y += 50;
|
||||
|
||||
@@ -622,10 +677,18 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||
.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(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
||||
{ align: 'center' });
|
||||
.text(errorAmountText, { align: 'center' });
|
||||
|
||||
doc.y += 50;
|
||||
|
||||
@@ -685,18 +748,56 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
|
||||
|
||||
try {
|
||||
// Fetch image directly from S3 URL
|
||||
const response = await fetch(rawPath, {
|
||||
// Ensure URL is properly encoded
|
||||
let encodedUrl = rawPath;
|
||||
try {
|
||||
// Parse and reconstruct URL to ensure proper encoding
|
||||
const url = new URL(rawPath);
|
||||
// Re-encode the pathname to handle special characters
|
||||
url.pathname = url.pathname.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))).join('/');
|
||||
encodedUrl = url.toString();
|
||||
console.log('[expenses/generate-pdf] URL encoded:', encodedUrl);
|
||||
} catch (urlError) {
|
||||
console.log('[expenses/generate-pdf] Using original URL (encoding failed):', rawPath);
|
||||
encodedUrl = rawPath;
|
||||
}
|
||||
|
||||
// Fetch image directly from S3 URL with proper headers
|
||||
const response = await fetch(encodedUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'image/*'
|
||||
'Accept': 'image/*',
|
||||
'User-Agent': 'PortNimara-Client-Portal/1.0',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
// Add timeout to prevent hanging
|
||||
signal: AbortSignal.timeout(30000) // 30 second timeout
|
||||
signal: AbortSignal.timeout(45000) // 45 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()));
|
||||
|
||||
// Try with the original URL if encoding failed
|
||||
if (encodedUrl !== rawPath) {
|
||||
console.log('[expenses/generate-pdf] Retrying with original URL...');
|
||||
const originalResponse = await fetch(rawPath, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'image/*',
|
||||
'User-Agent': 'PortNimara-Client-Portal/1.0'
|
||||
},
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
|
||||
if (originalResponse.ok) {
|
||||
const arrayBuffer = await originalResponse.arrayBuffer();
|
||||
const imageBuffer = Buffer.from(arrayBuffer);
|
||||
console.log('[expenses/generate-pdf] Successfully fetched with original URL, Size:', imageBuffer.length);
|
||||
return imageBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -710,26 +811,23 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||
} 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...');
|
||||
// If it's a timeout or network error, try one more time with simpler approach
|
||||
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError' || fetchError.code === 'ECONNRESET') {
|
||||
console.log('[expenses/generate-pdf] Network error, trying simplified approach...');
|
||||
try {
|
||||
const retryResponse = await fetch(rawPath, {
|
||||
const simpleResponse = await fetch(rawPath, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'image/*'
|
||||
},
|
||||
signal: AbortSignal.timeout(60000) // 60 second timeout for retry
|
||||
signal: AbortSignal.timeout(90000) // Extended timeout for final attempt
|
||||
});
|
||||
|
||||
if (retryResponse.ok) {
|
||||
const arrayBuffer = await retryResponse.arrayBuffer();
|
||||
if (simpleResponse.ok) {
|
||||
const arrayBuffer = await simpleResponse.arrayBuffer();
|
||||
const imageBuffer = Buffer.from(arrayBuffer);
|
||||
console.log('[expenses/generate-pdf] Successfully fetched image on retry, Size:', imageBuffer.length);
|
||||
console.log('[expenses/generate-pdf] Successfully fetched image with simplified approach, Size:', imageBuffer.length);
|
||||
return imageBuffer;
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('[expenses/generate-pdf] Retry also failed:', retryError);
|
||||
} catch (finalError) {
|
||||
console.error('[expenses/generate-pdf] Final attempt also failed:', finalError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user