package util import ( "encoding/json" "fmt" "io" "net/http" "sync" "time" ) // ExchangeRateResponse represents the response from the exchange rate API type ExchangeRateResponse struct { Success bool `json:"success"` Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` } // HistoricalExchangeRateResponse represents historical rate response type HistoricalExchangeRateResponse struct { Success bool `json:"success"` Historical bool `json:"historical"` Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` TimeLastUpdate int64 `json:"time_last_update_unix"` } // CachedRate stores exchange rate with timestamp type CachedRate struct { Rate float64 Timestamp time.Time Date string // The date this rate is for (YYYY-MM-DD format) } // Currency conversion cache var ( exchangeRateCache = make(map[string]CachedRate) cacheMutex sync.RWMutex cacheValidDuration = 24 * time.Hour // Cache historical rates for 24 hours currentRateCache = 1 * time.Hour // Cache current rates for 1 hour ) // GetExchangeRate fetches the current exchange rate from fromCurrency to toCurrency func GetExchangeRate(fromCurrency, toCurrency string) (float64, error) { return GetHistoricalExchangeRate(fromCurrency, toCurrency, time.Now()) } // GetHistoricalExchangeRate fetches the exchange rate for a specific date func GetHistoricalExchangeRate(fromCurrency, toCurrency string, date time.Time) (float64, error) { // If currencies are the same, return 1.0 if fromCurrency == toCurrency { return 1.0, nil } dateStr := date.Format("2006-01-02") cacheKey := fmt.Sprintf("%s_%s_%s", fromCurrency, toCurrency, dateStr) // Check cache first cacheMutex.RLock() if cached, exists := exchangeRateCache[cacheKey]; exists { // Determine cache validity based on whether it's historical or current validDuration := cacheValidDuration if isToday(date) { validDuration = currentRateCache } if time.Since(cached.Timestamp) < validDuration { cacheMutex.RUnlock() return cached.Rate, nil } } cacheMutex.RUnlock() // Fetch from API if not in cache or cache is expired rate, err := fetchHistoricalExchangeRateFromAPI(fromCurrency, toCurrency, date) if err != nil { return 0, err } // Cache the result cacheMutex.Lock() exchangeRateCache[cacheKey] = CachedRate{ Rate: rate, Timestamp: time.Now(), Date: dateStr, } cacheMutex.Unlock() return rate, nil } // fetchHistoricalExchangeRateFromAPI fetches exchange rate for a specific date func fetchHistoricalExchangeRateFromAPI(fromCurrency, toCurrency string, date time.Time) (float64, error) { var url string dateStr := date.Format("2006-01-02") today := time.Now().Format("2006-01-02") // Use different endpoints for historical vs current rates if dateStr == today { // Current rates url = fmt.Sprintf("https://api.exchangerate-api.com/v4/latest/%s", fromCurrency) } else { // Historical rates - try exchangerate-api.com historical endpoint url = fmt.Sprintf("https://api.exchangerate-api.com/v4/historical/%s/%s", fromCurrency, dateStr) } req, err := http.NewRequest("GET", url, nil) if err != nil { return 0, fmt.Errorf("error creating request: %v", err) } req.Header.Set("User-Agent", "Portfolio-Tracker/1.0") client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { return 0, fmt.Errorf("error fetching exchange rate: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // If historical API fails, try fallback strategy if dateStr != today { return fetchHistoricalRateFallback(fromCurrency, toCurrency, date) } return 0, fmt.Errorf("API returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("error reading response: %v", err) } // Try parsing as standard response first var apiResp struct { Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` } if err := json.Unmarshal(body, &apiResp); err != nil { return 0, fmt.Errorf("error parsing JSON: %v", err) } rate, exists := apiResp.Rates[toCurrency] if !exists { return 0, fmt.Errorf("currency %s not found in response", toCurrency) } return rate, nil } // fetchHistoricalRateFallback tries alternative methods for historical rates func fetchHistoricalRateFallback(fromCurrency, toCurrency string, date time.Time) (float64, error) { // Try frankfurter.app as fallback for historical rates (free and reliable) dateStr := date.Format("2006-01-02") url := fmt.Sprintf("https://api.frankfurter.app/%s?from=%s&to=%s", dateStr, fromCurrency, toCurrency) req, err := http.NewRequest("GET", url, nil) if err != nil { return 0, fmt.Errorf("error creating fallback request: %v", err) } req.Header.Set("User-Agent", "Portfolio-Tracker/1.0") client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { // If historical rate fails, use current rate as last resort return getCurrentRateAsFallback(fromCurrency, toCurrency) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return getCurrentRateAsFallback(fromCurrency, toCurrency) } body, err := io.ReadAll(resp.Body) if err != nil { return getCurrentRateAsFallback(fromCurrency, toCurrency) } var fallbackResp struct { Amount float64 `json:"amount"` Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` } if err := json.Unmarshal(body, &fallbackResp); err != nil { return getCurrentRateAsFallback(fromCurrency, toCurrency) } if rate, exists := fallbackResp.Rates[toCurrency]; exists { return rate, nil } return getCurrentRateAsFallback(fromCurrency, toCurrency) } // getCurrentRateAsFallback gets current rate when historical rate is unavailable func getCurrentRateAsFallback(fromCurrency, toCurrency string) (float64, error) { url := fmt.Sprintf("https://api.exchangerate-api.com/v4/latest/%s", fromCurrency) req, err := http.NewRequest("GET", url, nil) if err != nil { return 0, fmt.Errorf("error creating fallback request: %v", err) } req.Header.Set("User-Agent", "Portfolio-Tracker/1.0") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return 0, fmt.Errorf("error fetching fallback rate: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("fallback API returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("error reading fallback response: %v", err) } var apiResp struct { Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` } if err := json.Unmarshal(body, &apiResp); err != nil { return 0, fmt.Errorf("error parsing fallback JSON: %v", err) } rate, exists := apiResp.Rates[toCurrency] if !exists { return 0, fmt.Errorf("currency %s not found in fallback response", toCurrency) } return rate, nil } // ConvertCurrency converts an amount from one currency to another using current rates func ConvertCurrency(amount float64, fromCurrency, toCurrency string) (float64, error) { return ConvertCurrencyHistorical(amount, fromCurrency, toCurrency, time.Now()) } // ConvertCurrencyHistorical converts an amount using historical exchange rate for a specific date func ConvertCurrencyHistorical(amount float64, fromCurrency, toCurrency string, date time.Time) (float64, error) { if fromCurrency == toCurrency { return amount, nil } rate, err := GetHistoricalExchangeRate(fromCurrency, toCurrency, date) if err != nil { return 0, err } return amount * rate, nil } // FormatCurrencyAmount formats a currency amount with the currency symbol func FormatCurrencyAmount(amount float64, currency string) string { return fmt.Sprintf("%.2f %s", amount, currency) } // GetCurrencySymbol returns the symbol for common currencies func GetCurrencySymbol(currency string) string { symbols := map[string]string{ "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CHF": "CHF", "CAD": "C$", "AUD": "A$", "CNY": "¥", "INR": "₹", "KRW": "₩", "SEK": "kr", "NOK": "kr", "DKK": "kr", "PLN": "zł", "CZK": "Kč", "HUF": "Ft", } if symbol, exists := symbols[currency]; exists { return symbol } return currency // Return currency code if symbol not found } // FormatCurrencyWithSymbol formats amount with currency symbol func FormatCurrencyWithSymbol(amount float64, currency string) string { symbol := GetCurrencySymbol(currency) if symbol == currency { // If no symbol found, put currency after amount return fmt.Sprintf("%.2f %s", amount, currency) } // For most currencies, put symbol before amount switch currency { case "EUR": return fmt.Sprintf("%.2f %s", amount, symbol) default: return fmt.Sprintf("%s%.2f", symbol, amount) } } // ConvertAndFormat converts currency and formats it nicely func ConvertAndFormat(amount float64, fromCurrency, toCurrency string) string { return ConvertAndFormatHistorical(amount, fromCurrency, toCurrency, time.Now()) } // ConvertAndFormatHistorical converts currency using historical rate and formats it func ConvertAndFormatHistorical(amount float64, fromCurrency, toCurrency string, date time.Time) string { if fromCurrency == toCurrency { return FormatCurrencyWithSymbol(amount, toCurrency) } convertedAmount, err := ConvertCurrencyHistorical(amount, fromCurrency, toCurrency, date) if err != nil { // If conversion fails, return original amount with note return fmt.Sprintf("%.2f %s (conv. error)", amount, fromCurrency) } return FormatCurrencyWithSymbol(convertedAmount, toCurrency) } // BatchConvertCurrency converts multiple amounts in one API call for efficiency using current rates func BatchConvertCurrency(amounts []float64, fromCurrency, toCurrency string) ([]float64, error) { return BatchConvertCurrencyHistorical(amounts, fromCurrency, toCurrency, time.Now()) } // BatchConvertCurrencyHistorical converts multiple amounts using historical rate for a specific date func BatchConvertCurrencyHistorical(amounts []float64, fromCurrency, toCurrency string, date time.Time) ([]float64, error) { if fromCurrency == toCurrency { return amounts, nil } rate, err := GetHistoricalExchangeRate(fromCurrency, toCurrency, date) if err != nil { return nil, err } converted := make([]float64, len(amounts)) for i, amount := range amounts { converted[i] = amount * rate } return converted, nil } // ClearCache clears the exchange rate cache (useful for testing or manual refresh) func ClearCache() { cacheMutex.Lock() defer cacheMutex.Unlock() exchangeRateCache = make(map[string]CachedRate) } // GetCacheInfo returns information about cached exchange rates func GetCacheInfo() map[string]time.Time { cacheMutex.RLock() defer cacheMutex.RUnlock() info := make(map[string]time.Time) for key, cached := range exchangeRateCache { info[key] = cached.Timestamp } return info } // GetDetailedCacheInfo returns detailed information about cached exchange rates func GetDetailedCacheInfo() map[string]CachedRate { cacheMutex.RLock() defer cacheMutex.RUnlock() info := make(map[string]CachedRate) for key, cached := range exchangeRateCache { info[key] = cached } return info } // CleanExpiredCache removes expired cache entries func CleanExpiredCache() int { cacheMutex.Lock() defer cacheMutex.Unlock() removed := 0 now := time.Now() for key, cached := range exchangeRateCache { // Determine if this is a current or historical rate validDuration := cacheValidDuration if isToday(parseDate(cached.Date)) { validDuration = currentRateCache } if now.Sub(cached.Timestamp) > validDuration { delete(exchangeRateCache, key) removed++ } } return removed } // Helper functions // isToday checks if a date is today func isToday(date time.Time) bool { now := time.Now() return date.Year() == now.Year() && date.Month() == now.Month() && date.Day() == now.Day() } // parseDate parses a date string in YYYY-MM-DD format func parseDate(dateStr string) time.Time { date, err := time.Parse("2006-01-02", dateStr) if err != nil { return time.Time{} } return date } // GetExchangeRateWithFallback gets exchange rate with multiple fallback strategies func GetExchangeRateWithFallback(fromCurrency, toCurrency string, date time.Time) (float64, bool, error) { rate, err := GetHistoricalExchangeRate(fromCurrency, toCurrency, date) if err != nil { // Try with current date as fallback currentRate, currentErr := GetExchangeRate(fromCurrency, toCurrency) if currentErr != nil { return 0, false, fmt.Errorf("both historical (%v) and current (%v) rate fetching failed", err, currentErr) } return currentRate, false, nil // false indicates fallback was used } return rate, true, nil // true indicates historical rate was used }