447 lines
13 KiB
TypeScript
447 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;
|
|||
|
|
}> => {
|
|||
|
|
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)));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
cached: true,
|
|||
|
|
lastUpdated: cache.lastUpdated,
|
|||
|
|
ratesCount: Object.keys(cache.rates).length,
|
|||
|
|
minutesUntilExpiry
|
|||
|
|
};
|
|||
|
|
} 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;
|
|||
|
|
};
|