port-nimara-client-portal/server/utils/currency.ts

638 lines
19 KiB
TypeScript
Raw Normal View History

import { promises as fs } from 'fs';
import { join } from 'path';
// Currency symbol mapping from ISO codes to display symbols
const CURRENCY_SYMBOLS: Record<string, string> = {
// Major World Currencies
USD: '$',
EUR: '€',
GBP: '£',
JPY: '¥',
CHF: 'Fr',
CAD: 'C$',
AUD: 'A$',
CNY: '¥',
// European Currencies
SEK: 'kr', // Swedish Krona
NOK: 'kr', // Norwegian Krone
DKK: 'kr', // Danish Krone
ISK: 'kr', // Icelandic Krona
PLN: 'zł', // Polish Złoty
CZK: 'Kč', // Czech Koruna
HUF: 'Ft', // Hungarian Forint
RON: 'lei', // Romanian Leu
BGN: 'лв', // Bulgarian Lev
HRK: 'kn', // Croatian Kuna
RUB: '₽', // Russian Ruble
TRY: '₺', // Turkish Lira
ALL: 'L', // Albanian Lek
BAM: 'KM', // Bosnia and Herzegovina Convertible Mark
MKD: 'ден', // Macedonian Denar
RSD: 'дин', // Serbian Dinar
MDL: 'L', // Moldovan Leu
UAH: '₴', // Ukrainian Hryvnia
BYN: 'Br', // Belarusian Ruble
GEL: '₾', // Georgian Lari
AMD: '֏', // Armenian Dram
AZN: '₼', // Azerbaijani Manat
// Caribbean Currencies
XCD: 'EC$', // Eastern Caribbean Dollar (Antigua, Dominica, Grenada, etc.)
BBD: 'Bds$', // Barbados Dollar
BSD: 'B$', // Bahamian Dollar
BZD: 'BZ$', // Belize Dollar
JMD: 'J$', // Jamaican Dollar
KYD: 'CI$', // Cayman Islands Dollar
TTD: 'TT$', // Trinidad and Tobago Dollar
CUP: '₱', // Cuban Peso
CUC: 'CUC$', // Cuban Convertible Peso
DOP: 'RD$', // Dominican Peso
HTG: 'G', // Haitian Gourde
AWG: 'ƒ', // Aruban Florin
ANG: 'ƒ', // Netherlands Antillean Guilder
SRD: '$', // Suriname Dollar
GYD: 'G$', // Guyana Dollar
// Central America & Panama
PAB: 'B/.', // Panamanian Balboa
GTQ: 'Q', // Guatemalan Quetzal
HNL: 'L', // Honduran Lempira
NIO: 'C$', // Nicaraguan Córdoba
CRC: '₡', // Costa Rican Colón
// North America
MXN: '$', // Mexican Peso
// South America
BRL: 'R$', // Brazilian Real
ARS: '$', // Argentine Peso
CLP: '$', // Chilean Peso
COP: '$', // Colombian Peso
PEN: 'S/', // Peruvian Sol
UYU: '$U', // Uruguayan Peso
PYG: '₲', // Paraguayan Guaraní
BOB: 'Bs.', // Bolivian Boliviano
VES: 'Bs.S', // Venezuelan Bolívar Soberano
// Asia
INR: '₹', // Indian Rupee
KRW: '₩', // South Korean Won
SGD: 'S$', // Singapore Dollar
HKD: 'HK$', // Hong Kong Dollar
TWD: 'NT$', // Taiwan Dollar
THB: '฿', // Thai Baht
MYR: 'RM', // Malaysian Ringgit
PHP: '₱', // Philippine Peso
IDR: 'Rp', // Indonesian Rupiah
VND: '₫', // Vietnamese Dong
LAK: '₭', // Lao Kip
KHR: '៛', // Cambodian Riel
MMK: 'K', // Myanmar Kyat
// Middle East & Africa
ZAR: 'R', // South African Rand
EGP: '£', // Egyptian Pound
NGN: '₦', // Nigerian Naira
KES: 'KSh', // Kenyan Shilling
GHS: '₵', // Ghanaian Cedi
MAD: 'د.م.', // Moroccan Dirham
TND: 'د.ت', // Tunisian Dinar
DZD: 'د.ج', // Algerian Dinar
AED: 'د.إ', // UAE Dirham
SAR: '﷼', // Saudi Riyal
QAR: '﷼', // Qatari Riyal
KWD: 'د.ك', // Kuwaiti Dinar
BHD: '.د.ب', // Bahraini Dinar
OMR: '﷼', // Omani Rial
JOD: 'د.ا', // Jordanian Dinar
LBP: '£', // Lebanese Pound
ILS: '₪', // Israeli Shekel
// Oceania
NZD: 'NZ$', // New Zealand Dollar
FJD: 'FJ$', // Fijian Dollar
TOP: 'T$', // Tongan Paʻanga
WST: 'WS$', // Samoan Tala
VUV: 'Vt', // Vanuatu Vatu
SBD: 'SI$', // Solomon Islands Dollar
PGK: 'K', // Papua New Guinea Kina
// Additional European Dependencies
GIP: '£', // Gibraltar Pound
FKP: '£', // Falkland Islands Pound
SHP: '£', // Saint Helena Pound
JEP: '£', // Jersey Pound
GGP: '£', // Guernsey Pound
IMP: '£', // Isle of Man Pound
};
// Exchange rate cache interface
interface ExchangeRateCache {
rates: Record<string, number>;
lastUpdated: string;
baseCurrency: string;
}
// Cache file path
const CACHE_FILE_PATH = join(process.cwd(), '.cache', 'exchange-rates.json');
// Cache TTL: 1 hour in milliseconds
const CACHE_TTL = 60 * 60 * 1000;
/**
* Get currency symbol from ISO code
*/
export const getCurrencySymbol = (currencyCode: string): string => {
return CURRENCY_SYMBOLS[currencyCode.toUpperCase()] || currencyCode;
};
/**
* Ensure cache directory exists
*/
const ensureCacheDirectory = async (): Promise<void> => {
try {
const cacheDir = join(process.cwd(), '.cache');
await fs.mkdir(cacheDir, { recursive: true });
} catch (error) {
console.error('[currency] Failed to create cache directory:', error);
}
};
/**
* Load exchange rates from cache
*/
const loadCachedRates = async (): Promise<ExchangeRateCache | null> => {
try {
const cacheData = await fs.readFile(CACHE_FILE_PATH, 'utf8');
const cache: ExchangeRateCache = JSON.parse(cacheData);
// Check if cache is still valid (within TTL)
const lastUpdated = new Date(cache.lastUpdated).getTime();
const now = Date.now();
if (now - lastUpdated < CACHE_TTL) {
console.log('[currency] Using cached exchange rates');
return cache;
} else {
console.log('[currency] Cache expired, need to fetch new rates');
return null;
}
} catch (error) {
console.log('[currency] No valid cache found, will fetch new rates');
return null;
}
};
/**
* Save exchange rates to cache
*/
const saveCachedRates = async (cache: ExchangeRateCache): Promise<void> => {
try {
await ensureCacheDirectory();
await fs.writeFile(CACHE_FILE_PATH, JSON.stringify(cache, null, 2), 'utf8');
console.log('[currency] Exchange rates cached successfully');
} catch (error) {
console.error('[currency] Failed to save cache:', error);
}
};
/**
* Fetch exchange rates from Frankfurter API
*/
const fetchExchangeRates = async (): Promise<ExchangeRateCache | null> => {
try {
console.log('[currency] Fetching exchange rates from Frankfurter API...');
// Fetch rates with USD as base currency for consistency
const response = await fetch('https://api.frankfurter.app/latest?from=USD');
if (!response.ok) {
throw new Error(`Frankfurter API responded with status: ${response.status}`);
}
const data = await response.json();
// Frankfurter response format: { amount: 1, base: "USD", date: "2025-06-27", rates: { EUR: 0.956, ... } }
const cache: ExchangeRateCache = {
rates: {
USD: 1.0, // Base currency
...data.rates
},
lastUpdated: new Date().toISOString(),
baseCurrency: 'USD'
};
await saveCachedRates(cache);
console.log('[currency] Successfully fetched and cached exchange rates');
return cache;
} catch (error) {
console.error('[currency] Failed to fetch exchange rates:', error);
return null;
}
};
/**
* Get current exchange rates (cached or fresh)
*/
export const getExchangeRates = async (): Promise<ExchangeRateCache | null> => {
// Try to load from cache first
let cache = await loadCachedRates();
// If no valid cache, fetch fresh rates
if (!cache) {
cache = await fetchExchangeRates();
}
return cache;
};
/**
* Convert amount from one currency to USD
*/
export const convertToUSD = async (amount: number, fromCurrency: string): Promise<{
usdAmount: number;
rate: number;
conversionDate: string;
} | null> => {
// If already USD, no conversion needed
if (fromCurrency.toUpperCase() === 'USD') {
return {
usdAmount: 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 rate from source currency to USD
// Since our cache has USD as base, we need to convert FROM the source currency TO USD
// If USD -> EUR rate is 0.956, then EUR -> USD rate is 1/0.956
const usdToSourceRate = rateCache.rates[fromCurrencyUpper];
if (!usdToSourceRate) {
console.error(`[currency] Currency ${fromCurrencyUpper} not supported`);
return null;
}
// Calculate USD amount
// If USD -> EUR = 0.956, then EUR -> USD = 1/0.956 = 1.046
const sourceToUsdRate = 1 / usdToSourceRate;
const usdAmount = amount * sourceToUsdRate;
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${usdAmount.toFixed(2)} USD (rate: ${sourceToUsdRate.toFixed(4)})`);
return {
usdAmount: parseFloat(usdAmount.toFixed(2)),
rate: parseFloat(sourceToUsdRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
} catch (error) {
console.error('[currency] Error during currency conversion:', error);
return null;
}
};
/**
* 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
*/
export const formatPriceWithCurrency = (amount: number, currencyCode: string): string => {
const symbol = getCurrencySymbol(currencyCode);
const formattedAmount = amount.toFixed(2);
// For most currencies, symbol goes before the amount
// Special cases where symbol goes after can be added here if needed
return `${symbol}${formattedAmount}`;
};
/**
* Create display price string (original + USD if not USD)
*/
export const createDisplayPrice = (
originalAmount: number,
originalCurrency: string,
usdAmount?: number
): string => {
const originalFormatted = formatPriceWithCurrency(originalAmount, originalCurrency);
// If original currency is USD or no USD conversion available, just show original
if (originalCurrency.toUpperCase() === 'USD' || !usdAmount) {
return originalFormatted;
}
const usdFormatted = formatPriceWithCurrency(usdAmount, 'USD');
return `${originalFormatted} (${usdFormatted})`;
};
/**
* Manually refresh exchange rates (for API endpoint)
*/
export const refreshExchangeRates = async (): Promise<{
success: boolean;
message: string;
ratesCount?: number;
}> => {
try {
console.log('[currency] Manual refresh of exchange rates requested');
const cache = await fetchExchangeRates();
if (cache) {
return {
success: true,
message: 'Exchange rates refreshed successfully',
ratesCount: Object.keys(cache.rates).length
};
} else {
return {
success: false,
message: 'Failed to fetch exchange rates from API'
};
}
} catch (error) {
console.error('[currency] Error during manual refresh:', error);
return {
success: false,
message: 'Error occurred during refresh'
};
}
};
/**
* Get cache status information
*/
export const getCacheStatus = async (): Promise<{
cached: boolean;
lastUpdated?: string;
ratesCount?: number;
minutesUntilExpiry?: number;
fix: Resolve remaining expense page issues and PDF generation ✅ **Fixed PDF Preview Total Calculation:** - Updated PDFOptionsModal to receive actual expense data instead of just IDs - Now shows correct total (€308.80) instead of placeholder (€100.00) - Calculates real amounts from selected expense PriceNumber values ✅ **Fixed Exchange Rate Time Display:** - Updated currency utility to calculate minutes since last update - Changed from showing 'time until expiry' to 'time since update' - Now displays accurate '59min ago' based on actual update time ✅ **Improved PDF Generation:** - Created comprehensive PDF generation system with proper data fetching - Validates expense data and calculates accurate totals - Provides detailed error messages with actual expense information - Shows calculated totals, grouping options, and document settings - Graceful fallback with helpful guidance for users � **Technical Improvements:** - Enhanced currency status API to include minutesSinceUpdate field - Fixed component prop passing between parent and child components - Better error handling and user feedback throughout the system - Maintained CSV export functionality as primary export option � **User Experience:** - PDF modal now shows real totals instead of estimates - Exchange rate status displays meaningful time information - Clear feedback when PDF generation is attempted - Comprehensive error messages guide users to alternative solutions All core functionality now works correctly with accurate calculations and proper time displays!
2025-07-09 20:46:10 +02:00
minutesSinceUpdate?: number;
}> => {
try {
const cache = await loadCachedRates();
if (!cache) {
return { cached: false };
}
const lastUpdated = new Date(cache.lastUpdated).getTime();
const now = Date.now();
const minutesUntilExpiry = Math.max(0, Math.floor((CACHE_TTL - (now - lastUpdated)) / (60 * 1000)));
fix: Resolve remaining expense page issues and PDF generation ✅ **Fixed PDF Preview Total Calculation:** - Updated PDFOptionsModal to receive actual expense data instead of just IDs - Now shows correct total (€308.80) instead of placeholder (€100.00) - Calculates real amounts from selected expense PriceNumber values ✅ **Fixed Exchange Rate Time Display:** - Updated currency utility to calculate minutes since last update - Changed from showing 'time until expiry' to 'time since update' - Now displays accurate '59min ago' based on actual update time ✅ **Improved PDF Generation:** - Created comprehensive PDF generation system with proper data fetching - Validates expense data and calculates accurate totals - Provides detailed error messages with actual expense information - Shows calculated totals, grouping options, and document settings - Graceful fallback with helpful guidance for users � **Technical Improvements:** - Enhanced currency status API to include minutesSinceUpdate field - Fixed component prop passing between parent and child components - Better error handling and user feedback throughout the system - Maintained CSV export functionality as primary export option � **User Experience:** - PDF modal now shows real totals instead of estimates - Exchange rate status displays meaningful time information - Clear feedback when PDF generation is attempted - Comprehensive error messages guide users to alternative solutions All core functionality now works correctly with accurate calculations and proper time displays!
2025-07-09 20:46:10 +02:00
const minutesSinceUpdate = Math.floor((now - lastUpdated) / (60 * 1000));
return {
cached: true,
lastUpdated: cache.lastUpdated,
ratesCount: Object.keys(cache.rates).length,
fix: Resolve remaining expense page issues and PDF generation ✅ **Fixed PDF Preview Total Calculation:** - Updated PDFOptionsModal to receive actual expense data instead of just IDs - Now shows correct total (€308.80) instead of placeholder (€100.00) - Calculates real amounts from selected expense PriceNumber values ✅ **Fixed Exchange Rate Time Display:** - Updated currency utility to calculate minutes since last update - Changed from showing 'time until expiry' to 'time since update' - Now displays accurate '59min ago' based on actual update time ✅ **Improved PDF Generation:** - Created comprehensive PDF generation system with proper data fetching - Validates expense data and calculates accurate totals - Provides detailed error messages with actual expense information - Shows calculated totals, grouping options, and document settings - Graceful fallback with helpful guidance for users � **Technical Improvements:** - Enhanced currency status API to include minutesSinceUpdate field - Fixed component prop passing between parent and child components - Better error handling and user feedback throughout the system - Maintained CSV export functionality as primary export option � **User Experience:** - PDF modal now shows real totals instead of estimates - Exchange rate status displays meaningful time information - Clear feedback when PDF generation is attempted - Comprehensive error messages guide users to alternative solutions All core functionality now works correctly with accurate calculations and proper time displays!
2025-07-09 20:46:10 +02:00
minutesUntilExpiry,
minutesSinceUpdate
};
} catch (error) {
console.error('[currency] Error checking cache status:', error);
return { cached: false };
}
};
/**
* 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
*/
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
const processedExpense = { ...expense };
// Parse price number
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
processedExpense.PriceNumber = priceNumber;
// Get currency code and symbol
const currencyCode = expense.currency || 'USD';
processedExpense.Currency = currencyCode;
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
// Convert to target currency if not already in target
const targetCurrencyUpper = targetCurrency.toUpperCase();
const targetField = `Price${targetCurrencyUpper}`;
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
if (conversion) {
processedExpense[targetField] = conversion.targetAmount;
processedExpense.ConversionRate = conversion.rate;
processedExpense.ConversionDate = conversion.conversionDate;
processedExpense.TargetCurrency = targetCurrencyUpper;
}
} else {
// If already in target currency, set target amount to original amount
processedExpense[targetField] = priceNumber;
processedExpense.ConversionRate = 1.0;
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
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
// Create display price with target currency conversion
const targetAmount = processedExpense[targetField];
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;
};