import { promises as fs } from 'fs'; import { join } from 'path'; // Currency symbol mapping from ISO codes to display symbols const CURRENCY_SYMBOLS: Record = { // 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; 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 => { 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 => { 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 => { 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 => { 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 => { // 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; } }; /** * 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; 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))); const minutesSinceUpdate = Math.floor((now - lastUpdated) / (60 * 1000)); return { cached: true, lastUpdated: cache.lastUpdated, ratesCount: Object.keys(cache.rates).length, minutesUntilExpiry, minutesSinceUpdate }; } catch (error) { console.error('[currency] Error checking cache status:', error); return { cached: false }; } }; /** * Enhanced expense processing with currency conversion */ export const processExpenseWithCurrency = async (expense: any): Promise => { const processedExpense = { ...expense }; // Parse price number const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0; processedExpense.PriceNumber = priceNumber; // Get currency symbol const currencyCode = expense.currency || 'USD'; processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode); // Convert to USD if not already USD if (currencyCode.toUpperCase() !== 'USD') { const conversion = await convertToUSD(priceNumber, currencyCode); if (conversion) { processedExpense.PriceUSD = conversion.usdAmount; processedExpense.ConversionRate = conversion.rate; processedExpense.ConversionDate = conversion.conversionDate; } } else { // If already USD, set USD amount to original amount processedExpense.PriceUSD = priceNumber; processedExpense.ConversionRate = 1.0; processedExpense.ConversionDate = new Date().toISOString(); } // Create display prices processedExpense.DisplayPrice = createDisplayPrice( priceNumber, currencyCode, processedExpense.PriceUSD ); processedExpense.DisplayPriceUSD = formatPriceWithCurrency( processedExpense.PriceUSD || priceNumber, 'USD' ); return processedExpense; };