feat: Add currency selection and conversion support in PDF generation, enhance expense processing with target currency handling
This commit is contained in:
parent
2928d9a7ed
commit
3ba8542e4f
|
|
@ -132,8 +132,21 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
|
<!-- Currency Selection -->
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
v-model="options.targetCurrency"
|
||||||
|
:items="currencyOptions"
|
||||||
|
label="Export Currency"
|
||||||
|
variant="outlined"
|
||||||
|
item-title="text"
|
||||||
|
item-value="value"
|
||||||
|
prepend-inner-icon="mdi-currency-usd"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
<!-- Page Format -->
|
<!-- Page Format -->
|
||||||
<v-col cols="12">
|
<v-col cols="12" md="6">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="options.pageFormat"
|
v-model="options.pageFormat"
|
||||||
:items="pageFormatOptions"
|
:items="pageFormatOptions"
|
||||||
|
|
@ -222,6 +235,7 @@ interface PDFOptions {
|
||||||
includeDetails: boolean;
|
includeDetails: boolean;
|
||||||
includeProcessingFee: boolean;
|
includeProcessingFee: boolean;
|
||||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
|
targetCurrency: 'USD' | 'EUR';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed dialog model
|
// Computed dialog model
|
||||||
|
|
@ -243,7 +257,8 @@ const options = ref<PDFOptions>({
|
||||||
includeSummary: true,
|
includeSummary: true,
|
||||||
includeDetails: true,
|
includeDetails: true,
|
||||||
includeProcessingFee: true,
|
includeProcessingFee: true,
|
||||||
pageFormat: 'A4'
|
pageFormat: 'A4',
|
||||||
|
targetCurrency: 'EUR'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form options
|
// Form options
|
||||||
|
|
@ -260,6 +275,11 @@ const pageFormatOptions = [
|
||||||
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currencyOptions = [
|
||||||
|
{ text: 'Euro (EUR)', value: 'EUR' },
|
||||||
|
{ text: 'US Dollar (USD)', value: 'USD' }
|
||||||
|
];
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: string) => !!value || 'This field is required'
|
required: (value: string) => !!value || 'This field is required'
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,36 @@
|
||||||
v-model="showCreateModal"
|
v-model="showCreateModal"
|
||||||
@created="handleExpenseCreated"
|
@created="handleExpenseCreated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- PDF Generation Loading Overlay -->
|
||||||
|
<v-overlay
|
||||||
|
:model-value="generatingPDF"
|
||||||
|
persistent
|
||||||
|
class="align-center justify-center"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
color="surface"
|
||||||
|
class="pa-8"
|
||||||
|
width="400"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
:size="70"
|
||||||
|
:width="7"
|
||||||
|
color="primary"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 class="text-h6 mt-4 mb-2">Generating PDF...</h3>
|
||||||
|
<p class="text-body-2 text-grey-darken-1">
|
||||||
|
Your expense report is being generated with receipt images
|
||||||
|
</p>
|
||||||
|
<p class="text-caption text-grey-darken-1 mt-2">
|
||||||
|
This may take a moment for large reports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-overlay>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -324,6 +354,7 @@ const showDetailsModal = ref(false);
|
||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
const selectedExpense = ref<Expense | null>(null);
|
const selectedExpense = ref<Expense | null>(null);
|
||||||
const activeTab = ref<string>('');
|
const activeTab = ref<string>('');
|
||||||
|
const generatingPDF = ref(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
|
|
@ -494,6 +525,9 @@ const exportCSV = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const generatePDF = async (options: any) => {
|
const generatePDF = async (options: any) => {
|
||||||
|
generatingPDF.value = true;
|
||||||
|
showPDFModal.value = false; // Close the modal immediately
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[expenses] Generating PDF with options:', options);
|
console.log('[expenses] Generating PDF with options:', options);
|
||||||
|
|
||||||
|
|
@ -536,11 +570,11 @@ const generatePDF = async (options: any) => {
|
||||||
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
|
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
showPDFModal.value = false;
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[expenses] Error generating PDF:', err);
|
console.error('[expenses] Error generating PDF:', err);
|
||||||
error.value = err.message || 'Failed to generate PDF';
|
error.value = err.message || 'Failed to generate PDF';
|
||||||
|
} finally {
|
||||||
|
generatingPDF.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface PDFOptions {
|
||||||
includeDetails: boolean;
|
includeDetails: boolean;
|
||||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
includeProcessingFee?: boolean;
|
includeProcessingFee?: boolean;
|
||||||
|
targetCurrency?: 'USD' | 'EUR';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Expense {
|
interface Expense {
|
||||||
|
|
@ -23,7 +24,11 @@ interface Expense {
|
||||||
'Establishment Name': string;
|
'Establishment Name': string;
|
||||||
Price: string;
|
Price: string;
|
||||||
PriceNumber: number;
|
PriceNumber: number;
|
||||||
|
Currency?: string;
|
||||||
|
CurrencySymbol?: string;
|
||||||
DisplayPrice: string;
|
DisplayPrice: string;
|
||||||
|
DisplayPriceWithEUR?: string;
|
||||||
|
PriceEUR?: number;
|
||||||
PriceUSD?: number;
|
PriceUSD?: number;
|
||||||
ConversionRate?: number;
|
ConversionRate?: number;
|
||||||
Payer: string;
|
Payer: string;
|
||||||
|
|
@ -57,12 +62,13 @@ export default defineEventHandler(async (event) => {
|
||||||
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
|
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch expense data
|
// Fetch expense data with target currency processing
|
||||||
|
const targetCurrency = options.targetCurrency || 'EUR';
|
||||||
const expenses: Expense[] = [];
|
const expenses: Expense[] = [];
|
||||||
for (const expenseId of expenseIds) {
|
for (const expenseId of expenseIds) {
|
||||||
const expense = await getExpenseById(expenseId);
|
const expense = await getExpenseById(expenseId);
|
||||||
if (expense) {
|
if (expense) {
|
||||||
const processedExpense = await processExpenseWithCurrency(expense);
|
const processedExpense = await processExpenseWithCurrency(expense, targetCurrency);
|
||||||
expenses.push(processedExpense);
|
expenses.push(processedExpense);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +81,7 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate totals
|
// 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] Successfully calculated totals:', totals);
|
||||||
console.log('[expenses/generate-pdf] Options received:', options);
|
console.log('[expenses/generate-pdf] Options received:', options);
|
||||||
|
|
@ -105,18 +111,33 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false) {
|
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false, targetCurrency: string = 'EUR') {
|
||||||
const originalTotal = expenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
// 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 usdTotal = expenses.reduce((sum, exp) => sum + (exp.PriceUSD || exp.PriceNumber || 0), 0);
|
||||||
|
|
||||||
const processingFee = includeProcessingFee ? originalTotal * 0.05 : 0;
|
// Processing fee is calculated on target currency total
|
||||||
const finalTotal = originalTotal + processingFee;
|
const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0;
|
||||||
|
const finalTotal = targetTotal + processingFee;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originalTotal,
|
targetTotal,
|
||||||
|
eurTotal,
|
||||||
usdTotal,
|
usdTotal,
|
||||||
processingFee,
|
processingFee,
|
||||||
finalTotal,
|
finalTotal,
|
||||||
|
targetCurrency: targetCurrency.toUpperCase(),
|
||||||
count: expenses.length
|
count: expenses.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -242,34 +263,43 @@ function addSummary(doc: PDFKit.PDFDocument, totals: any, options: PDFOptions) {
|
||||||
.font('Helvetica');
|
.font('Helvetica');
|
||||||
|
|
||||||
const leftX = 80;
|
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 })
|
doc.text(`Total Expenses:`, leftX, doc.y, { continued: true })
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.text(` ${totals.count}`, { align: 'left' });
|
.text(` ${totals.count}`, { align: 'left' });
|
||||||
|
|
||||||
doc.font('Helvetica')
|
doc.font('Helvetica')
|
||||||
.text(`Subtotal:`, leftX, doc.y + 5, { continued: true })
|
.text(`Subtotal (${targetCurrency}):`, leftX, doc.y + 5, { continued: true })
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.text(` €${totals.originalTotal.toFixed(2)}`, { align: 'left' });
|
.text(` ${targetSymbol}${totals.targetTotal.toFixed(2)}`, { align: 'left' });
|
||||||
|
|
||||||
doc.font('Helvetica')
|
// Show the other currency as reference
|
||||||
.text(`USD Equivalent:`, leftX, doc.y + 5, { continued: true })
|
if (targetCurrency === 'USD') {
|
||||||
.font('Helvetica-Bold')
|
doc.font('Helvetica')
|
||||||
.text(` $${totals.usdTotal.toFixed(2)}`, { align: 'left' });
|
.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) {
|
if (options.includeProcessingFee) {
|
||||||
doc.font('Helvetica')
|
doc.font('Helvetica')
|
||||||
.text(`Processing Fee (5%):`, leftX, doc.y + 5, { continued: true })
|
.text(`Processing Fee (5%):`, leftX, doc.y + 5, { continued: true })
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.text(` €${totals.processingFee.toFixed(2)}`, { align: 'left' });
|
.text(` ${targetSymbol}${totals.processingFee.toFixed(2)}`, { align: 'left' });
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.font('Helvetica')
|
doc.font('Helvetica')
|
||||||
.text(`Final Total:`, leftX, doc.y + 5, { continued: true })
|
.text(`Final Total:`, leftX, doc.y + 5, { continued: true })
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.fontSize(14)
|
.fontSize(14)
|
||||||
.text(` €${totals.finalTotal.toFixed(2)}`, { align: 'left' });
|
.text(` ${targetSymbol}${totals.finalTotal.toFixed(2)}`, { align: 'left' });
|
||||||
|
|
||||||
doc.fontSize(12)
|
doc.fontSize(12)
|
||||||
.font('Helvetica')
|
.font('Helvetica')
|
||||||
|
|
@ -338,8 +368,8 @@ async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], opt
|
||||||
currentY = 60;
|
currentY = 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group header
|
// Group header - show EUR total
|
||||||
const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
const groupEurTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceEUR || exp.PriceNumber || 0), 0);
|
||||||
doc.fontSize(fontSize + 1)
|
doc.fontSize(fontSize + 1)
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.fillColor('#000000');
|
.fillColor('#000000');
|
||||||
|
|
@ -351,7 +381,7 @@ async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], opt
|
||||||
.stroke();
|
.stroke();
|
||||||
|
|
||||||
doc.fillColor('#000000')
|
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 });
|
65, currentY + 8, { width: doc.page.width - 130 });
|
||||||
|
|
||||||
currentY += rowHeight;
|
currentY += rowHeight;
|
||||||
|
|
@ -392,12 +422,21 @@ async function drawExpenseRows(
|
||||||
|
|
||||||
doc.fillColor('#000000');
|
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 date = expense.Time ? formatDate(expense.Time) : 'N/A';
|
||||||
const establishment = expense['Establishment Name'] || 'N/A';
|
const establishment = expense['Establishment Name'] || 'N/A';
|
||||||
const category = expense.Category || 'N/A';
|
const category = expense.Category || 'N/A';
|
||||||
const payer = expense.Payer || '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 payment = expense['Payment Method'] || 'N/A';
|
||||||
|
|
||||||
const rowData = [date, establishment, category, payer, amount, payment];
|
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}`,
|
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
|
||||||
70, 80, { align: 'left' });
|
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)
|
doc.fontSize(14)
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.text(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
.text(amountText, 70, 105, { align: 'left' });
|
||||||
70, 105, { align: 'left' });
|
|
||||||
|
|
||||||
doc.fontSize(12)
|
doc.fontSize(12)
|
||||||
.font('Helvetica')
|
.font('Helvetica')
|
||||||
|
|
@ -597,10 +644,18 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||||
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
|
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
|
||||||
{ align: 'center' });
|
{ 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)
|
doc.fontSize(14)
|
||||||
.font('Helvetica')
|
.font('Helvetica')
|
||||||
.text(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
.text(centerAmountText, { align: 'center' });
|
||||||
{ align: 'center' });
|
|
||||||
|
|
||||||
doc.y += 50;
|
doc.y += 50;
|
||||||
|
|
||||||
|
|
@ -622,10 +677,18 @@ async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
|
||||||
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
|
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
|
||||||
{ align: 'center' });
|
{ 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)
|
doc.fontSize(14)
|
||||||
.font('Helvetica')
|
.font('Helvetica')
|
||||||
.text(`${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`,
|
.text(errorAmountText, { align: 'center' });
|
||||||
{ align: 'center' });
|
|
||||||
|
|
||||||
doc.y += 50;
|
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...');
|
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch image directly from S3 URL
|
// Ensure URL is properly encoded
|
||||||
const response = await fetch(rawPath, {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'image/*'
|
'Accept': 'image/*',
|
||||||
|
'User-Agent': 'PortNimara-Client-Portal/1.0',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
},
|
},
|
||||||
// Add timeout to prevent hanging
|
// Add timeout to prevent hanging
|
||||||
signal: AbortSignal.timeout(30000) // 30 second timeout
|
signal: AbortSignal.timeout(45000) // 45 second timeout
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -710,26 +811,23 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||||
} catch (fetchError: any) {
|
} catch (fetchError: any) {
|
||||||
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
|
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 it's a timeout or network error, try one more time with simpler approach
|
||||||
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError') {
|
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError' || fetchError.code === 'ECONNRESET') {
|
||||||
console.log('[expenses/generate-pdf] Retrying with longer timeout...');
|
console.log('[expenses/generate-pdf] Network error, trying simplified approach...');
|
||||||
try {
|
try {
|
||||||
const retryResponse = await fetch(rawPath, {
|
const simpleResponse = await fetch(rawPath, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
signal: AbortSignal.timeout(90000) // Extended timeout for final attempt
|
||||||
'Accept': 'image/*'
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(60000) // 60 second timeout for retry
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (retryResponse.ok) {
|
if (simpleResponse.ok) {
|
||||||
const arrayBuffer = await retryResponse.arrayBuffer();
|
const arrayBuffer = await simpleResponse.arrayBuffer();
|
||||||
const imageBuffer = Buffer.from(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;
|
return imageBuffer;
|
||||||
}
|
}
|
||||||
} catch (retryError) {
|
} catch (finalError) {
|
||||||
console.error('[expenses/generate-pdf] Retry also failed:', retryError);
|
console.error('[expenses/generate-pdf] Final attempt also failed:', finalError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,80 @@ export const convertToUSD = async (amount: number, fromCurrency: string): Promis
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from one currency to EUR
|
||||||
|
*/
|
||||||
|
export const convertToEUR = async (amount: number, fromCurrency: string): Promise<{
|
||||||
|
eurAmount: number;
|
||||||
|
rate: number;
|
||||||
|
conversionDate: string;
|
||||||
|
} | null> => {
|
||||||
|
// If already EUR, no conversion needed
|
||||||
|
if (fromCurrency.toUpperCase() === 'EUR') {
|
||||||
|
return {
|
||||||
|
eurAmount: amount,
|
||||||
|
rate: 1.0,
|
||||||
|
conversionDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rateCache = await getExchangeRates();
|
||||||
|
|
||||||
|
if (!rateCache) {
|
||||||
|
console.error('[currency] No exchange rates available for conversion');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromCurrencyUpper = fromCurrency.toUpperCase();
|
||||||
|
|
||||||
|
// Get USD -> EUR rate
|
||||||
|
const usdToEurRate = rateCache.rates['EUR'];
|
||||||
|
|
||||||
|
if (!usdToEurRate) {
|
||||||
|
console.error('[currency] EUR rate not available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If converting from USD to EUR
|
||||||
|
if (fromCurrencyUpper === 'USD') {
|
||||||
|
const eurAmount = amount * usdToEurRate;
|
||||||
|
console.log(`[currency] Converted ${amount} USD to ${eurAmount.toFixed(2)} EUR (rate: ${usdToEurRate.toFixed(4)})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eurAmount: parseFloat(eurAmount.toFixed(2)),
|
||||||
|
rate: parseFloat(usdToEurRate.toFixed(4)),
|
||||||
|
conversionDate: rateCache.lastUpdated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other currencies, convert through USD first
|
||||||
|
const usdToSourceRate = rateCache.rates[fromCurrencyUpper];
|
||||||
|
|
||||||
|
if (!usdToSourceRate) {
|
||||||
|
console.error(`[currency] Currency ${fromCurrencyUpper} not supported`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate: Source -> USD -> EUR
|
||||||
|
// Source -> USD: amount / usdToSourceRate
|
||||||
|
// USD -> EUR: (amount / usdToSourceRate) * usdToEurRate
|
||||||
|
const sourceToEurRate = usdToEurRate / usdToSourceRate;
|
||||||
|
const eurAmount = amount * sourceToEurRate;
|
||||||
|
|
||||||
|
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${eurAmount.toFixed(2)} EUR (rate: ${sourceToEurRate.toFixed(4)})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eurAmount: parseFloat(eurAmount.toFixed(2)),
|
||||||
|
rate: parseFloat(sourceToEurRate.toFixed(4)),
|
||||||
|
conversionDate: rateCache.lastUpdated
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[currency] Error during EUR conversion:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format price with currency symbol
|
* Format price with currency symbol
|
||||||
*/
|
*/
|
||||||
|
|
@ -403,46 +477,160 @@ export const getCacheStatus = async (): Promise<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert amount from any currency to target currency
|
||||||
|
*/
|
||||||
|
export const convertToTargetCurrency = async (
|
||||||
|
amount: number,
|
||||||
|
fromCurrency: string,
|
||||||
|
targetCurrency: string
|
||||||
|
): Promise<{
|
||||||
|
targetAmount: number;
|
||||||
|
rate: number;
|
||||||
|
conversionDate: string;
|
||||||
|
} | null> => {
|
||||||
|
// If same currency, no conversion needed
|
||||||
|
if (fromCurrency.toUpperCase() === targetCurrency.toUpperCase()) {
|
||||||
|
return {
|
||||||
|
targetAmount: amount,
|
||||||
|
rate: 1.0,
|
||||||
|
conversionDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing functions for specific conversions
|
||||||
|
if (targetCurrency.toUpperCase() === 'USD') {
|
||||||
|
const result = await convertToUSD(amount, fromCurrency);
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
targetAmount: result.usdAmount,
|
||||||
|
rate: result.rate,
|
||||||
|
conversionDate: result.conversionDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetCurrency.toUpperCase() === 'EUR') {
|
||||||
|
const result = await convertToEUR(amount, fromCurrency);
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
targetAmount: result.eurAmount,
|
||||||
|
rate: result.rate,
|
||||||
|
conversionDate: result.conversionDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other currencies, convert through USD
|
||||||
|
try {
|
||||||
|
const rateCache = await getExchangeRates();
|
||||||
|
|
||||||
|
if (!rateCache) {
|
||||||
|
console.error('[currency] No exchange rates available for conversion');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromCurrencyUpper = fromCurrency.toUpperCase();
|
||||||
|
const targetCurrencyUpper = targetCurrency.toUpperCase();
|
||||||
|
|
||||||
|
// Get rates
|
||||||
|
const usdToFromRate = rateCache.rates[fromCurrencyUpper];
|
||||||
|
const usdToTargetRate = rateCache.rates[targetCurrencyUpper];
|
||||||
|
|
||||||
|
if (!usdToFromRate || !usdToTargetRate) {
|
||||||
|
console.error(`[currency] Currency not supported: ${!usdToFromRate ? fromCurrencyUpper : targetCurrencyUpper}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate: Source -> USD -> Target
|
||||||
|
const fromToTargetRate = usdToTargetRate / usdToFromRate;
|
||||||
|
const targetAmount = amount * fromToTargetRate;
|
||||||
|
|
||||||
|
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${targetAmount.toFixed(2)} ${targetCurrencyUpper} (rate: ${fromToTargetRate.toFixed(4)})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetAmount: parseFloat(targetAmount.toFixed(2)),
|
||||||
|
rate: parseFloat(fromToTargetRate.toFixed(4)),
|
||||||
|
conversionDate: rateCache.lastUpdated
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[currency] Error during currency conversion:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced expense processing with currency conversion
|
* Enhanced expense processing with currency conversion
|
||||||
*/
|
*/
|
||||||
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
|
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
|
||||||
const processedExpense = { ...expense };
|
const processedExpense = { ...expense };
|
||||||
|
|
||||||
// Parse price number
|
// Parse price number
|
||||||
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||||
processedExpense.PriceNumber = priceNumber;
|
processedExpense.PriceNumber = priceNumber;
|
||||||
|
|
||||||
// Get currency symbol
|
// Get currency code and symbol
|
||||||
const currencyCode = expense.currency || 'USD';
|
const currencyCode = expense.currency || 'USD';
|
||||||
|
processedExpense.Currency = currencyCode;
|
||||||
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
||||||
|
|
||||||
// Convert to USD if not already USD
|
// Convert to target currency if not already in target
|
||||||
if (currencyCode.toUpperCase() !== 'USD') {
|
const targetCurrencyUpper = targetCurrency.toUpperCase();
|
||||||
const conversion = await convertToUSD(priceNumber, currencyCode);
|
const targetField = `Price${targetCurrencyUpper}`;
|
||||||
|
|
||||||
|
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
|
||||||
|
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
|
||||||
|
|
||||||
if (conversion) {
|
if (conversion) {
|
||||||
processedExpense.PriceUSD = conversion.usdAmount;
|
processedExpense[targetField] = conversion.targetAmount;
|
||||||
processedExpense.ConversionRate = conversion.rate;
|
processedExpense.ConversionRate = conversion.rate;
|
||||||
processedExpense.ConversionDate = conversion.conversionDate;
|
processedExpense.ConversionDate = conversion.conversionDate;
|
||||||
|
processedExpense.TargetCurrency = targetCurrencyUpper;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If already USD, set USD amount to original amount
|
// If already in target currency, set target amount to original amount
|
||||||
processedExpense.PriceUSD = priceNumber;
|
processedExpense[targetField] = priceNumber;
|
||||||
processedExpense.ConversionRate = 1.0;
|
processedExpense.ConversionRate = 1.0;
|
||||||
processedExpense.ConversionDate = new Date().toISOString();
|
processedExpense.ConversionDate = new Date().toISOString();
|
||||||
|
processedExpense.TargetCurrency = targetCurrencyUpper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also convert to USD and EUR for compatibility
|
||||||
|
if (currencyCode.toUpperCase() !== 'USD') {
|
||||||
|
const usdConversion = await convertToUSD(priceNumber, currencyCode);
|
||||||
|
if (usdConversion) {
|
||||||
|
processedExpense.PriceUSD = usdConversion.usdAmount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processedExpense.PriceUSD = priceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currencyCode.toUpperCase() !== 'EUR') {
|
||||||
|
const eurConversion = await convertToEUR(priceNumber, currencyCode);
|
||||||
|
if (eurConversion) {
|
||||||
|
processedExpense.PriceEUR = eurConversion.eurAmount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processedExpense.PriceEUR = priceNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create display prices
|
// Create display prices
|
||||||
processedExpense.DisplayPrice = createDisplayPrice(
|
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
|
||||||
priceNumber,
|
|
||||||
currencyCode,
|
|
||||||
processedExpense.PriceUSD
|
|
||||||
);
|
|
||||||
|
|
||||||
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
|
// Create display price with target currency conversion
|
||||||
processedExpense.PriceUSD || priceNumber,
|
const targetAmount = processedExpense[targetField];
|
||||||
'USD'
|
if (currencyCode.toUpperCase() !== targetCurrencyUpper && targetAmount) {
|
||||||
|
const targetSymbol = getCurrencySymbol(targetCurrency);
|
||||||
|
processedExpense.DisplayPriceWithTarget = `${formatPriceWithCurrency(priceNumber, currencyCode)} (${targetSymbol}${targetAmount.toFixed(2)})`;
|
||||||
|
} else {
|
||||||
|
processedExpense.DisplayPriceWithTarget = formatPriceWithCurrency(priceNumber, currencyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedExpense.DisplayPriceTarget = formatPriceWithCurrency(
|
||||||
|
targetAmount || priceNumber,
|
||||||
|
targetCurrency
|
||||||
);
|
);
|
||||||
|
|
||||||
return processedExpense;
|
return processedExpense;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue