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

450 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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