feat: Add currency selection and conversion support in PDF generation, enhance expense processing with target currency handling

This commit is contained in:
Matt 2025-07-10 14:02:14 -04:00
parent 2928d9a7ed
commit 3ba8542e4f
4 changed files with 405 additions and 65 deletions

View File

@ -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'

View File

@ -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;
} }
}; };

View File

@ -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);
} }
} }

View File

@ -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;