445 lines
13 KiB
Go
445 lines
13 KiB
Go
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
|
|
}
|