450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
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;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 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<any> => {
|
||
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;
|
||
};
|