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

638 lines
19 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;
}
};
/**
* 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;
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 };
}
};
/**
* 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;
};