app is now using historical exchange rates for transactions

This commit is contained in:
Matthias Hinrichs
2025-07-05 03:44:53 +02:00
parent a96bbe4d2a
commit aef9342cc5
11 changed files with 926 additions and 237 deletions
+230 -16
View File
@@ -17,32 +17,56 @@ type ExchangeRateResponse struct {
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 = 1 * time.Hour // Cache rates for 1 hour
cacheValidDuration = 24 * time.Hour // Cache historical rates for 24 hours
currentRateCache = 1 * time.Hour // Cache current rates for 1 hour
)
// GetExchangeRate fetches the exchange rate from fromCurrency to toCurrency
// 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
}
cacheKey := fmt.Sprintf("%s_%s", fromCurrency, toCurrency)
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 {
if time.Since(cached.Timestamp) < cacheValidDuration {
// 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
}
@@ -50,7 +74,7 @@ func GetExchangeRate(fromCurrency, toCurrency string) (float64, error) {
cacheMutex.RUnlock()
// Fetch from API if not in cache or cache is expired
rate, err := fetchExchangeRateFromAPI(fromCurrency, toCurrency)
rate, err := fetchHistoricalExchangeRateFromAPI(fromCurrency, toCurrency, date)
if err != nil {
return 0, err
}
@@ -60,16 +84,27 @@ func GetExchangeRate(fromCurrency, toCurrency string) (float64, error) {
exchangeRateCache[cacheKey] = CachedRate{
Rate: rate,
Timestamp: time.Now(),
Date: dateStr,
}
cacheMutex.Unlock()
return rate, nil
}
// fetchExchangeRateFromAPI fetches exchange rate from a free API
func fetchExchangeRateFromAPI(fromCurrency, toCurrency string) (float64, error) {
// Using exchangerate-api.com (completely free, no API key required)
url := fmt.Sprintf("https://api.exchangerate-api.com/v4/latest/%s", fromCurrency)
// 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 {
@@ -78,7 +113,7 @@ func fetchExchangeRateFromAPI(fromCurrency, toCurrency string) (float64, error)
req.Header.Set("User-Agent", "Portfolio-Tracker/1.0")
client := &http.Client{Timeout: 10 * time.Second}
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)
@@ -86,6 +121,10 @@ func fetchExchangeRateFromAPI(fromCurrency, toCurrency string) (float64, error)
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)
}
@@ -94,7 +133,7 @@ func fetchExchangeRateFromAPI(fromCurrency, toCurrency string) (float64, error)
return 0, fmt.Errorf("error reading response: %v", err)
}
// Parse exchangerate-api.com response format
// Try parsing as standard response first
var apiResp struct {
Base string `json:"base"`
Date string `json:"date"`
@@ -113,13 +152,111 @@ func fetchExchangeRateFromAPI(fromCurrency, toCurrency string) (float64, error)
return rate, nil
}
// ConvertCurrency converts an amount from one currency to another
// 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 := GetExchangeRate(fromCurrency, toCurrency)
rate, err := GetHistoricalExchangeRate(fromCurrency, toCurrency, date)
if err != nil {
return 0, err
}
@@ -178,11 +315,16 @@ func FormatCurrencyWithSymbol(amount float64, currency string) string {
// 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 := ConvertCurrency(amount, fromCurrency, 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)
@@ -191,13 +333,18 @@ func ConvertAndFormat(amount float64, fromCurrency, toCurrency string) string {
return FormatCurrencyWithSymbol(convertedAmount, toCurrency)
}
// BatchConvertCurrency converts multiple amounts in one API call for efficiency
// 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 := GetExchangeRate(fromCurrency, toCurrency)
rate, err := GetHistoricalExchangeRate(fromCurrency, toCurrency, date)
if err != nil {
return nil, err
}
@@ -228,3 +375,70 @@ func GetCacheInfo() map[string]time.Time {
}
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
}