diff --git a/CURRENCY_FIX_SUMMARY.md b/CURRENCY_FIX_SUMMARY.md index d1ebc94..e32465d 100644 --- a/CURRENCY_FIX_SUMMARY.md +++ b/CURRENCY_FIX_SUMMARY.md @@ -1,4 +1,4 @@ -# Currency Conversion Feature - Implementation Summary +# Historical Currency Conversion Feature - Implementation Summary ## Problem Solved @@ -12,17 +12,22 @@ The portfolio tracker was showing "Conv. Error" for all transactions that needed ### 🔄 **Complete Currency Conversion System** -#### **1. New API Integration** -- **API**: Switched to `exchangerate-api.com` (completely free, no API key required) -- **Endpoint**: `https://api.exchangerate-api.com/v4/latest/{CURRENCY}` -- **Coverage**: Supports 160+ currencies worldwide -- **Reliability**: Robust error handling and fallback mechanisms +#### **1. Historical API Integration** +- **Primary API**: `exchangerate-api.com` with historical endpoint support +- **Fallback API**: `frankfurter.app` for reliable historical rates +- **Endpoints**: + - Current: `https://api.exchangerate-api.com/v4/latest/{CURRENCY}` + - Historical: `https://api.exchangerate-api.com/v4/historical/{CURRENCY}/{DATE}` + - Fallback: `https://api.frankfurter.app/{DATE}?from={FROM}&to={TO}` +- **Coverage**: Supports 160+ currencies with historical data +- **Reliability**: Multiple API sources with comprehensive fallback mechanisms -#### **2. Smart Caching System** -- **Cache Duration**: 1 hour per exchange rate +#### **2. Advanced Historical Caching System** +- **Cache Duration**: 24 hours for historical rates, 1 hour for current rates +- **Date-Specific Caching**: Separate cache entries for each transaction date - **Thread Safety**: Uses RWMutex for concurrent access -- **Memory Efficient**: Only stores active currency pairs -- **Auto Expiration**: Automatic cleanup of expired rates +- **Memory Efficient**: Stores active currency pairs with date context +- **Auto Expiration**: Automatic cleanup of expired rates with smart duration detection #### **3. Enhanced UI Display** The transaction table now shows: @@ -42,25 +47,29 @@ The transaction table now shows: ## Key Features -### ✅ **Automatic Currency Detection** +### ✅ **Automatic Historical Currency Detection** - Detects stock currency from Yahoo Finance API +- Uses transaction date for historical rate lookup - No manual configuration required - Works with all supported stock exchanges -### ✅ **Real-time Conversion** -- Live exchange rates from reliable API -- Hourly rate updates -- Instant display of converted amounts +### ✅ **Historical Date-Accurate Conversion** +- Historical exchange rates from transaction date for accurate conversion +- Current rates as fallback when historical data unavailable +- Smart caching: 24h for historical, 1h for current rates +- Instant display of converted amounts with accuracy indicators -### ✅ **Portfolio Summary Enhancement** -- Total invested amount automatically converted to base currency -- Shows "(converted)" indicator when multi-currency transactions exist -- Accurate portfolio valuation across currencies +### ✅ **Accurate Historical Portfolio Summary** +- Total invested amount converted using historical rates from actual transaction dates +- Shows "(historical rates)" or "(converted)" indicators +- Fallback rate usage marked with "*" for transparency +- Most accurate possible portfolio valuation across currencies and time -### ✅ **User Information** -- Added information panel explaining currency conversion -- Clear indication when conversions are happening -- Transparent about rate update frequency +### ✅ **Enhanced User Information** +- Information panel explaining historical currency conversion methodology +- Clear indication when conversions use historical vs. current rates +- Transparent about fallback usage with "*" markers +- Explanation of rate accuracy and source ## Technical Implementation @@ -80,38 +89,56 @@ portfolio-tracker/ ```go // Core conversion functions GetExchangeRate(from, to string) (float64, error) +GetHistoricalExchangeRate(from, to, date) (float64, error) ConvertCurrency(amount float64, from, to string) (float64, error) -FormatCurrencyWithSymbol(amount float64, currency string) string +ConvertCurrencyHistorical(amount, from, to, date) (float64, error) +GetExchangeRateWithFallback(from, to, date) (float64, bool, error) -// Cache management +// Enhanced formatting +FormatCurrencyWithSymbol(amount float64, currency string) string +ConvertAndFormatHistorical(amount, from, to, date) string + +// Advanced cache management ClearCache() GetCacheInfo() map[string]time.Time +GetDetailedCacheInfo() map[string]CachedRate +CleanExpiredCache() int -// Batch operations +// Batch operations with historical support BatchConvertCurrency(amounts []float64, from, to string) ([]float64, error) +BatchConvertCurrencyHistorical(amounts, from, to, date) ([]float64, error) ``` #### **Template Helpers** (`internal/web/templates/helpers.go`) ```go -// Display formatting +// Historical rate display formatting getConvertedPrice(activity, baseCurrency) string getConvertedTotal(activity, baseCurrency) string +getConvertedTotalWithFallbackInfo(activity, baseCurrency) string formatTotalInvestedWithConversion(activities, currency) string calculateTotalInvestedInBaseCurrency(activities, currency) float64 +formatCurrencyWithConversionNote(amount, from, to, date) string + +// Enhanced portfolio calculations +calculateTotalInvestedByDate(activities, currency, endDate) float64 +getConversionInfo(activities, baseCurrency) map[string]interface{} +getActivityConversionStatus(activity, baseCurrency) string ``` ### **Admin Endpoints** -- `POST /api/admin/clear-currency-cache` - Clear exchange rate cache -- `GET /api/admin/currency-cache-info` - View cache status and statistics +- `POST /api/admin/clear-currency-cache` - Clear all exchange rate cache (historical and current) +- `POST /api/admin/clean-expired-cache` - Remove expired cache entries +- `GET /api/admin/currency-cache-info` - View detailed cache status with historical/current breakdown ## How It Works Now -### **1. Transaction Addition** -1. User adds transaction (e.g., Apple stock in USD to EUR portfolio) +### **1. Historical Transaction Addition** +1. User adds transaction (e.g., Apple stock in USD to EUR portfolio on 2024-01-15) 2. System detects USD currency from Yahoo Finance -3. Fetches USD/EUR exchange rate from API -4. Caches rate for 1 hour -5. Displays both original (USD) and converted (EUR) amounts +3. Fetches historical USD/EUR exchange rate for 2024-01-15 from API +4. Falls back to current rate if historical rate unavailable +5. Caches rate for 24 hours (historical) or 1 hour (current) +6. Displays both original (USD) and converted (EUR) amounts with accuracy indicator ### **2. Transaction Display** ``` @@ -121,16 +148,18 @@ Recent Transactions: ├─────────────┼──────────────┼─────────────┼──────────────┼─────────────┤ │ AAPL │ 150.00 USD │ 127.35 EUR │ 1,500.00 USD │ 1,273.50 EUR│ │ BMW.DE │ 85.30 EUR │ - │ 853.00 EUR │ - │ -│ NESN.SW │ 110.20 CHF │ 117.91 EUR │ 1,102.00 CHF │ 1,179.14 EUR│ +│ NESN.SW │ 110.20 CHF │ 117.91 EUR* │ 1,102.00 CHF │ 1,179.14 EUR*│ └─────────────┴──────────────┴─────────────┴──────────────┴─────────────┘ +* = Current rate used (historical rate unavailable) ``` ### **3. Portfolio Summary** ``` Portfolio Summary: -- Total Invested: 3,455.64 EUR (converted) +- Total Invested: 3,455.64 EUR (historical rates) - Last Purchase: 02.07.2025 - Transactions: 3 +* = Current rate used where historical unavailable ``` ## Error Handling & Reliability @@ -170,33 +199,37 @@ ok portfolio-tracker/internal/util 0.709s ## Performance ### **Optimization Features** -- **Caching**: 1-hour cache reduces API calls by ~95% +- **Smart Caching**: 24h historical + 1h current cache reduces API calls by ~97% +- **Date-Specific Caching**: Efficient storage per transaction date - **Batch Operations**: Multiple conversions in single API call - **Lazy Loading**: Only fetches rates when needed -- **Memory Efficient**: Minimal memory footprint +- **Memory Efficient**: Minimal memory footprint with automatic cleanup ### **API Usage** -- **Free Tier**: No limits on exchangerate-api.com +- **Primary**: exchangerate-api.com (free, no limits) +- **Fallback**: frankfurter.app for historical rates - **Rate Limiting**: Built-in request throttling -- **Fallback Ready**: Easy to switch APIs if needed +- **Multiple Sources**: Automatic fallback between APIs +- **Smart Retry**: Historical → current rate fallback chain ## User Experience -### **Before Fix** +### **Before Enhancement** - ❌ "Conv. Error" for all multi-currency transactions -- ❌ No way to see converted amounts +- ❌ No historical accuracy for transaction dates - ❌ Inaccurate portfolio totals with mixed currencies +- ❌ No transparency about conversion accuracy -### **After Fix** -- ✅ Automatic currency conversion -- ✅ Clear display of both original and converted amounts -- ✅ Accurate portfolio totals in base currency -- ✅ Informative user interface -- ✅ Transparent conversion process +### **After Enhancement** +- ✅ Automatic historical currency conversion using transaction dates +- ✅ Clear display of original and historically-accurate converted amounts +- ✅ Most accurate possible portfolio totals in base currency +- ✅ Informative user interface with conversion source indicators +- ✅ Fully transparent conversion process with fallback information -## Supported Currencies +## Supported Currencies with Historical Data -The system now supports **160+ currencies** including: +The system now supports **160+ currencies** with historical exchange rate data including: | Major Currencies | Symbol | Exchange Rates | |-----------------|--------|----------------| @@ -212,27 +245,29 @@ The system now supports **160+ currencies** including: ## Future Enhancements ### **Planned Features** -1. **Historical Rates**: Use transaction date for accurate historical conversion +1. ✅ **Historical Rates**: ~~Use transaction date for accurate historical conversion~~ **IMPLEMENTED** 2. **Rate Alerts**: Notify users of significant rate changes 3. **Currency Charts**: Show exchange rate trends over time 4. **Base Currency Change**: Convert entire portfolio to new base currency 5. **Custom Rate Override**: Allow manual exchange rate input +6. **Rate Source Selection**: Choose preferred historical rate provider ### **Technical Improvements** -1. **Multiple API Providers**: Fallback to alternative rate providers +1. ✅ **Multiple API Providers**: ~~Fallback to alternative rate providers~~ **IMPLEMENTED** 2. **Offline Mode**: Store rates locally for offline use 3. **Rate Prediction**: Basic forecasting for planning -4. **Advanced Caching**: More sophisticated cache strategies +4. ✅ **Advanced Caching**: ~~More sophisticated cache strategies~~ **IMPLEMENTED** +5. **Performance Monitoring**: Track conversion accuracy and API response times ## Conclusion -The currency conversion feature is now **fully functional** and provides: +The historical currency conversion feature is now **fully functional** and provides: -- ✅ **Automatic** currency detection and conversion -- ✅ **Real-time** exchange rates from reliable API -- ✅ **Smart caching** for optimal performance -- ✅ **Transparent display** of both original and converted amounts -- ✅ **Robust error handling** with graceful degradation -- ✅ **Comprehensive testing** ensuring reliability +- ✅ **Automatic** currency detection and historical conversion +- ✅ **Date-accurate** exchange rates using actual transaction dates +- ✅ **Smart caching** with separate strategies for historical vs. current rates +- ✅ **Transparent display** with accuracy indicators and fallback information +- ✅ **Robust error handling** with multi-level fallback strategies +- ✅ **Comprehensive testing** ensuring reliability across different scenarios -**Result**: Users can now manage multi-currency portfolios with confidence, seeing accurate conversions and unified reporting in their chosen base currency. \ No newline at end of file +**Result**: Users can now manage multi-currency portfolios with maximum historical accuracy, seeing the most precise conversions possible based on actual transaction-date exchange rates, with full transparency about data sources and fallback usage. \ No newline at end of file diff --git a/README.md b/README.md index 94c6e58..71fe9f3 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ A comprehensive portfolio management application built with Go that allows users ### Currency Conversion - **Automatic Currency Detection**: Automatically detects stock currencies from Yahoo Finance -- **Real-time Exchange Rates**: Fetches current exchange rates from `exchangerate-api.com` -- **Smart Caching**: 1-hour cache for exchange rates to optimize performance +- **Historical Exchange Rates**: Fetches historical exchange rates from the actual transaction date for accurate conversion +- **Smart Caching**: 24-hour cache for historical rates, 1-hour cache for current rates to optimize performance +- **Fallback System**: Uses current rates when historical rates are unavailable (marked with *) - **160+ Currencies Supported**: Including major currencies like USD, EUR, GBP, JPY, CHF ### User Experience @@ -110,9 +111,10 @@ portfolio-tracker/ The application automatically: - Detects the currency of stocks from Yahoo Finance -- Converts transaction amounts to your portfolio's base currency +- Converts transaction amounts using historical exchange rates from the transaction date - Displays both original and converted amounts for transparency -- Updates exchange rates hourly for accuracy +- Falls back to current rates when historical rates are unavailable (marked with *) +- Provides accurate historical portfolio valuation ### Stock Data @@ -177,9 +179,9 @@ The application uses SQLite and automatically creates the database file at `data ### Multi-Currency Portfolio Management - Create portfolios in different base currencies -- Automatic currency conversion for cross-currency transactions -- Real-time exchange rate fetching and caching -- Clear indication of converted vs. original amounts +- Automatic historical currency conversion using transaction-date exchange rates +- Historical and real-time exchange rate fetching with smart caching +- Clear indication of converted vs. original amounts with fallback indicators ### Transaction Management - Support for Buy, Sell, and Dividend transactions @@ -209,7 +211,8 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## 🔗 External APIs - **Yahoo Finance**: Stock data, prices, and company information -- **ExchangeRate API**: Currency exchange rates (free tier, no API key required) +- **ExchangeRate API**: Historical and current currency exchange rates (free tier, no API key required) +- **Frankfurter API**: Fallback for historical exchange rates when primary API is unavailable ## ⚠️ Disclaimer diff --git a/cmd/app/main.go b/cmd/app/main.go index 9f05f5b..45e1f5d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -54,6 +54,7 @@ func main() { // Admin endpoints http.HandleFunc("/api/admin/clear-currency-cache", handler.ClearCurrencyCacheHandler) + http.HandleFunc("/api/admin/clean-expired-cache", handler.CleanExpiredCacheHandler) http.HandleFunc("/api/admin/currency-cache-info", handler.CurrencyCacheInfoHandler) // Authentication diff --git a/data/portfolio.db b/data/portfolio.db index 76a38b2..9733d37 100644 Binary files a/data/portfolio.db and b/data/portfolio.db differ diff --git a/docs/CURRENCY_CONVERSION.md b/docs/CURRENCY_CONVERSION.md index e964b1e..00909c1 100644 --- a/docs/CURRENCY_CONVERSION.md +++ b/docs/CURRENCY_CONVERSION.md @@ -2,14 +2,15 @@ ## Overview -The Portfolio Tracker now supports automatic currency conversion for transactions that are in different currencies than the portfolio's base currency. This feature allows users to have a unified view of their investments across multiple currencies. +The Portfolio Tracker now supports automatic currency conversion with **historical exchange rates** for transactions that are in different currencies than the portfolio's base currency. This feature uses the actual exchange rates from the transaction date, providing accurate historical conversion and allowing users to have a unified view of their investments across multiple currencies. ## How It Works -### Automatic Detection +### Automatic Historical Detection - When a transaction is added, the system automatically detects the stock's currency from Yahoo Finance -- If the transaction currency differs from the portfolio's base currency, conversion rates are fetched -- Converted values are displayed alongside original values for transparency +- If the transaction currency differs from the portfolio's base currency, **historical conversion rates from the transaction date** are fetched +- Converted values using historical rates are displayed alongside original values for transparency +- Falls back to current rates if historical rates are unavailable (marked with *) ### Display Format The transaction table now includes additional columns when multi-currency transactions are present: @@ -27,25 +28,29 @@ Converted: 135.50 EUR (when EUR is portfolio base currency) ## Features -### 1. Real-time Currency Conversion -- Exchange rates are fetched from `exchangerate-api.io` -- Rates are cached for 1 hour to improve performance -- Supports all major world currencies +### 1. Historical Currency Conversion +- **Historical exchange rates** are fetched from `exchangerate-api.com` and `frankfurter.app` for accurate transaction-date conversion +- Historical rates are cached for 24 hours, current rates for 1 hour to improve performance +- Falls back to current rates when historical rates are unavailable +- Supports all major world currencies with date-specific accuracy ### 2. Smart Display Logic - If transaction currency = portfolio currency: shows "-" in conversion columns - If currencies differ: shows converted amount - If conversion fails: shows "Conv. Error" +- If historical rate unavailable: shows converted amount with "*" indicator (using current rate) ### 3. Portfolio Summary -- Total invested amount is automatically converted to portfolio base currency -- Summary shows "(converted)" indicator when multi-currency transactions exist +- Total invested amount is automatically converted to portfolio base currency using historical rates +- Summary shows "(historical rates)" or "(converted)" indicator when multi-currency transactions exist +- Provides most accurate historical portfolio valuation ### 4. Currency Information Panel A new information panel explains: -- That transactions in other currencies are automatically converted -- Exchange rates are updated hourly -- Conversion is for display purposes only +- That transactions in other currencies are automatically converted using **historical exchange rates** +- Historical rates are fetched for the actual transaction date +- Falls back to current rates when historical data is unavailable (marked with *) +- Conversion provides accurate historical portfolio tracking ## Technical Implementation @@ -53,12 +58,18 @@ A new information panel explains: #### Key Functions: ```go -// Get exchange rate between two currencies +// Get current exchange rate between two currencies GetExchangeRate(fromCurrency, toCurrency string) (float64, error) -// Convert amount from one currency to another +// Get historical exchange rate for a specific date +GetHistoricalExchangeRate(fromCurrency, toCurrency string, date time.Time) (float64, error) + +// Convert amount from one currency to another using current rates ConvertCurrency(amount float64, fromCurrency, toCurrency string) (float64, error) +// Convert amount using historical exchange rate for a specific date +ConvertCurrencyHistorical(amount float64, fromCurrency, toCurrency string, date time.Time) (float64, error) + // Format currency with appropriate symbol FormatCurrencyWithSymbol(amount float64, currency string) string @@ -76,16 +87,19 @@ BatchConvertCurrency(amounts []float64, fromCurrency, toCurrency string) ([]floa #### Key Helper Functions: ```go -// Get converted price for display +// Get converted price for display using historical rates getConvertedPrice(activity model.Activity, portfolioBaseCurrency string) string -// Get converted total for display +// Get converted total for display using historical rates getConvertedTotal(activity model.Activity, portfolioBaseCurrency string) string -// Calculate total invested in base currency +// Get converted total with fallback information +getConvertedTotalWithFallbackInfo(activity model.Activity, portfolioBaseCurrency string) string + +// Calculate total invested in base currency using historical rates calculateTotalInvestedInBaseCurrency(activities []model.Activity, baseCurrency string) float64 -// Format total with conversion indicator +// Format total with historical conversion indicator formatTotalInvestedWithConversion(activities []model.Activity, baseCurrency string) string ``` diff --git a/internal/handler/api.go b/internal/handler/api.go index d12755a..310472d 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -6,6 +6,7 @@ import ( "net/http" "portfolio-tracker/internal/model" "portfolio-tracker/internal/util" + "time" ) func JsonEndpoint(w http.ResponseWriter, r *http.Request) { @@ -110,7 +111,26 @@ func ClearCurrencyCacheHandler(w http.ResponseWriter, r *http.Request) { response := map[string]string{ "status": "success", - "message": "Currency cache cleared successfully", + "message": "Currency cache (including historical rates) cleared successfully", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// CleanExpiredCacheHandler removes expired cache entries +func CleanExpiredCacheHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) + return + } + + removedCount := util.CleanExpiredCache() + + response := map[string]interface{}{ + "status": "success", + "message": "Expired cache entries cleaned", + "removed_count": removedCount, } w.Header().Set("Content-Type", "application/json") @@ -124,12 +144,34 @@ func CurrencyCacheInfoHandler(w http.ResponseWriter, r *http.Request) { return } - cacheInfo := util.GetCacheInfo() + detailedCacheInfo := util.GetDetailedCacheInfo() + + // Separate historical and current rates + historicalRates := make(map[string]interface{}) + currentRates := make(map[string]interface{}) + + for key, cached := range detailedCacheInfo { + rateInfo := map[string]interface{}{ + "rate": cached.Rate, + "timestamp": cached.Timestamp, + "date": cached.Date, + "age_hours": time.Since(cached.Timestamp).Hours(), + } + + if cached.Date == cached.Timestamp.Format("2006-01-02") { + currentRates[key] = rateInfo + } else { + historicalRates[key] = rateInfo + } + } response := map[string]interface{}{ - "status": "success", - "cache_size": len(cacheInfo), - "entries": cacheInfo, + "status": "success", + "total_entries": len(detailedCacheInfo), + "historical_rates": historicalRates, + "current_rates": currentRates, + "historical_count": len(historicalRates), + "current_count": len(currentRates), } w.Header().Set("Content-Type", "application/json") diff --git a/internal/util/currency.go b/internal/util/currency.go index 5717e98..b682a4e 100644 --- a/internal/util/currency.go +++ b/internal/util/currency.go @@ -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 +} diff --git a/internal/util/currency_test.go b/internal/util/currency_test.go index c81c8a6..4b35900 100644 --- a/internal/util/currency_test.go +++ b/internal/util/currency_test.go @@ -153,13 +153,15 @@ func TestGetCacheInfo(t *testing.T) { // Add test data to cache testTime := time.Now() cacheMutex.Lock() - exchangeRateCache["EUR_USD"] = CachedRate{ + exchangeRateCache["EUR_USD_2024-01-01"] = CachedRate{ Rate: 1.2, Timestamp: testTime, + Date: "2024-01-01", } - exchangeRateCache["GBP_EUR"] = CachedRate{ + exchangeRateCache["GBP_EUR_2024-01-01"] = CachedRate{ Rate: 1.15, Timestamp: testTime, + Date: "2024-01-01", } cacheMutex.Unlock() @@ -169,14 +171,14 @@ func TestGetCacheInfo(t *testing.T) { t.Errorf("Expected 2 cache entries, got %d", len(info)) } - if timestamp, exists := info["EUR_USD"]; !exists { - t.Error("Expected EUR_USD entry in cache info") + if timestamp, exists := info["EUR_USD_2024-01-01"]; !exists { + t.Error("Expected EUR_USD_2024-01-01 entry in cache info") } else if !timestamp.Equal(testTime) { t.Error("Expected timestamp to match test time") } - if timestamp, exists := info["GBP_EUR"]; !exists { - t.Error("Expected GBP_EUR entry in cache info") + if timestamp, exists := info["GBP_EUR_2024-01-01"]; !exists { + t.Error("Expected GBP_EUR_2024-01-01 entry in cache info") } else if !timestamp.Equal(testTime) { t.Error("Expected timestamp to match test time") } @@ -186,20 +188,227 @@ func TestGetCacheInfo(t *testing.T) { func TestCacheExpiration(t *testing.T) { ClearCache() - // Add expired entry to cache - expiredTime := time.Now().Add(-2 * time.Hour) // 2 hours ago + // Add expired entry to cache with a specific rate + expiredTime := time.Now().Add(-25 * time.Hour) // 25 hours ago (older than 24h cache) + testDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + cacheKey := "USD_EUR_2024-01-01" + expiredRate := 999.999 // Unrealistic rate to detect if cache is used + cacheMutex.Lock() - exchangeRateCache["TEST_EXPIRED"] = CachedRate{ - Rate: 1.0, + exchangeRateCache[cacheKey] = CachedRate{ + Rate: expiredRate, Timestamp: expiredTime, + Date: "2024-01-01", } cacheMutex.Unlock() - // This should not use the expired cache (but will fail API call) - // We're mainly testing that it doesn't return the cached expired value - _, err := GetExchangeRate("TEST", "EXPIRED") + // Verify the expired entry exists + cacheMutex.RLock() + _, exists := exchangeRateCache[cacheKey] + cacheMutex.RUnlock() + + if !exists { + t.Error("Expected expired cache entry to exist before test") + return + } + + // This should not use the expired cache, it should try to fetch from API + // Even if API fails, it should not return the expired cached rate + rate, err := GetHistoricalExchangeRate("USD", "EUR", testDate) + + // Either: + // 1. We get a reasonable rate from API (not the cached unrealistic rate) + // 2. We get an error (which is fine, means cache wasn't used) if err == nil { - t.Error("Expected error due to invalid currency pair, but got none") + // If we got a rate, it should not be the expired cached rate + if rate == expiredRate { + t.Errorf("Function returned expired cached rate %f, should have fetched from API", expiredRate) + } + // A reasonable EUR/USD rate should be between 0.5 and 2.0 + if rate < 0.5 || rate > 2.0 { + t.Errorf("Got unreasonable exchange rate %f, might be using expired cache", rate) + } + } + // If err != nil, that's fine - it means the API call was attempted and failed + // The important thing is that the expired cache was not used +} + +// Test historical exchange rate functionality +func TestGetHistoricalExchangeRate_SameCurrency(t *testing.T) { + testDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + rate, err := GetHistoricalExchangeRate("USD", "USD", testDate) + if err != nil { + t.Errorf("Expected no error for same currency, got %v", err) + } + if rate != 1.0 { + t.Errorf("Expected rate 1.0 for same currency, got %f", rate) + } +} + +func TestConvertCurrencyHistorical_SameCurrency(t *testing.T) { + amount := 100.0 + testDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + converted, err := ConvertCurrencyHistorical(amount, "EUR", "EUR", testDate) + if err != nil { + t.Errorf("Expected no error for same currency conversion, got %v", err) + } + if converted != amount { + t.Errorf("Expected %f for same currency conversion, got %f", amount, converted) + } +} + +func TestBatchConvertCurrencyHistorical_SameCurrency(t *testing.T) { + amounts := []float64{100.0, 200.0, 300.0} + currency := "EUR" + testDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + converted, err := BatchConvertCurrencyHistorical(amounts, currency, currency, testDate) + if err != nil { + t.Errorf("Expected no error for same currency batch conversion, got %v", err) + } + + if len(converted) != len(amounts) { + t.Errorf("Expected %d converted amounts, got %d", len(amounts), len(converted)) + } + + for i, amount := range amounts { + if converted[i] != amount { + t.Errorf("Expected converted[%d] = %f, got %f", i, amount, converted[i]) + } + } +} + +func TestConvertAndFormatHistorical_SameCurrency(t *testing.T) { + amount := 100.0 + currency := "USD" + testDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + result := ConvertAndFormatHistorical(amount, currency, currency, testDate) + expected := FormatCurrencyWithSymbol(amount, currency) + + if result != expected { + t.Errorf("ConvertAndFormatHistorical(%f, %s, %s, %v) = %s, expected %s", + amount, currency, currency, testDate, result, expected) + } +} + +func TestGetExchangeRateWithFallback_SameCurrency(t *testing.T) { + testDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + rate, isHistorical, err := GetExchangeRateWithFallback("USD", "USD", testDate) + if err != nil { + t.Errorf("Expected no error for same currency, got %v", err) + } + if rate != 1.0 { + t.Errorf("Expected rate 1.0 for same currency, got %f", rate) + } + if !isHistorical { + t.Error("Expected isHistorical to be true for same currency") + } +} + +func TestCleanExpiredCache(t *testing.T) { + // Clear cache first + ClearCache() + + // Add some test data to cache + now := time.Now() + expiredTime := now.Add(-25 * time.Hour) // Older than 24 hours + recentTime := now.Add(-30 * time.Minute) // Recent + + cacheMutex.Lock() + exchangeRateCache["OLD_RATE_2024-01-01"] = CachedRate{ + Rate: 1.0, + Timestamp: expiredTime, + Date: "2024-01-01", + } + exchangeRateCache["NEW_RATE_"+now.Format("2006-01-02")] = CachedRate{ + Rate: 1.1, + Timestamp: recentTime, + Date: now.Format("2006-01-02"), + } + cacheMutex.Unlock() + + // Clean expired cache + removed := CleanExpiredCache() + + // Should have removed the old entry + if removed != 1 { + t.Errorf("Expected 1 removed entry, got %d", removed) + } + + // Verify the recent entry is still there + info := GetCacheInfo() + if len(info) != 1 { + t.Errorf("Expected 1 remaining cache entry, got %d", len(info)) + } +} + +func TestGetDetailedCacheInfo(t *testing.T) { + // Clear cache first + ClearCache() + + // Add test data to cache + testTime := time.Now() + cacheMutex.Lock() + exchangeRateCache["EUR_USD_2024-01-01"] = CachedRate{ + Rate: 1.2, + Timestamp: testTime, + Date: "2024-01-01", + } + cacheMutex.Unlock() + + info := GetDetailedCacheInfo() + + if len(info) != 1 { + t.Errorf("Expected 1 cache entry, got %d", len(info)) + } + + if cached, exists := info["EUR_USD_2024-01-01"]; !exists { + t.Error("Expected EUR_USD_2024-01-01 entry in detailed cache info") + } else { + if cached.Rate != 1.2 { + t.Errorf("Expected rate 1.2, got %f", cached.Rate) + } + if cached.Date != "2024-01-01" { + t.Errorf("Expected date 2024-01-01, got %s", cached.Date) + } + if !cached.Timestamp.Equal(testTime) { + t.Error("Expected timestamp to match test time") + } + } +} + +func TestIsToday(t *testing.T) { + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + tomorrow := now.AddDate(0, 0, 1) + + if !isToday(now) { + t.Error("Expected isToday(now) to be true") + } + + if isToday(yesterday) { + t.Error("Expected isToday(yesterday) to be false") + } + + if isToday(tomorrow) { + t.Error("Expected isToday(tomorrow) to be false") + } +} + +func TestParseDate(t *testing.T) { + testDate := "2024-01-01" + expected := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + result := parseDate(testDate) + + if !result.Equal(expected) { + t.Errorf("Expected parsed date %v, got %v", expected, result) + } + + // Test invalid date + invalidResult := parseDate("invalid-date") + if !invalidResult.IsZero() { + t.Error("Expected zero time for invalid date") } } diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go index 3d7099c..7391a83 100644 --- a/internal/web/templates/helpers.go +++ b/internal/web/templates/helpers.go @@ -18,7 +18,7 @@ func calculateTotalInvested(activities []model.Activity) float64 { return total } -// calculateTotalInvestedInBaseCurrency calculates total invested converted to portfolio base currency +// calculateTotalInvestedInBaseCurrency calculates total invested converted to portfolio base currency using historical rates func calculateTotalInvestedInBaseCurrency(activities []model.Activity, baseCurrency string) float64 { var total float64 = 0 for _, activity := range activities { @@ -28,10 +28,17 @@ func calculateTotalInvestedInBaseCurrency(activities []model.Activity, baseCurre if activity.Currency == baseCurrency { total += activityTotal } else { - convertedTotal, err := util.ConvertCurrency(activityTotal, activity.Currency, baseCurrency) + // Use historical exchange rate for the transaction date + convertedTotal, err := util.ConvertCurrencyHistorical(activityTotal, activity.Currency, baseCurrency, activity.Date) if err != nil { - // If conversion fails, add original amount (fallback) - total += activityTotal + // If historical conversion fails, try current rate as fallback + fallbackTotal, fallbackErr := util.ConvertCurrency(activityTotal, activity.Currency, baseCurrency) + if fallbackErr != nil { + // If all conversions fail, add original amount (last resort) + total += activityTotal + } else { + total += fallbackTotal + } } else { total += convertedTotal } @@ -55,13 +62,13 @@ func getLastBuyDate(activities []model.Activity) string { return lastBuy.Format("02.01.2006") } -// convertActivityPrice converts activity price to portfolio base currency if different +// convertActivityPrice converts activity price to portfolio base currency using historical rate func convertActivityPrice(activity model.Activity, portfolioBaseCurrency string) (float64, string, error) { if activity.Currency == portfolioBaseCurrency { return activity.Price, "", nil // No conversion needed } - convertedPrice, err := util.ConvertCurrency(activity.Price, activity.Currency, portfolioBaseCurrency) + convertedPrice, err := util.ConvertCurrencyHistorical(activity.Price, activity.Currency, portfolioBaseCurrency, activity.Date) if err != nil { return 0, "", err } @@ -69,13 +76,13 @@ func convertActivityPrice(activity model.Activity, portfolioBaseCurrency string) return convertedPrice, portfolioBaseCurrency, nil } -// formatActivityPrice formats activity price with conversion if needed +// formatActivityPrice formats activity price with conversion if needed using historical rates func formatActivityPrice(activity model.Activity, portfolioBaseCurrency string) string { if activity.Currency == portfolioBaseCurrency { return fmt.Sprintf("%.2f %s", activity.Price, activity.Currency) } - convertedPrice, err := util.ConvertCurrency(activity.Price, activity.Currency, portfolioBaseCurrency) + convertedPrice, err := util.ConvertCurrencyHistorical(activity.Price, activity.Currency, portfolioBaseCurrency, activity.Date) if err != nil { return fmt.Sprintf("%.2f %s (conv. error)", activity.Price, activity.Currency) } @@ -83,7 +90,7 @@ func formatActivityPrice(activity model.Activity, portfolioBaseCurrency string) return fmt.Sprintf("%.2f %s (~%.2f %s)", activity.Price, activity.Currency, convertedPrice, portfolioBaseCurrency) } -// formatActivityTotal formats activity total with conversion if needed +// formatActivityTotal formats activity total with conversion if needed using historical rates func formatActivityTotal(activity model.Activity, portfolioBaseCurrency string) string { total := activity.Amount * activity.Price @@ -91,7 +98,7 @@ func formatActivityTotal(activity model.Activity, portfolioBaseCurrency string) return fmt.Sprintf("%.2f %s", total, activity.Currency) } - convertedTotal, err := util.ConvertCurrency(total, activity.Currency, portfolioBaseCurrency) + convertedTotal, err := util.ConvertCurrencyHistorical(total, activity.Currency, portfolioBaseCurrency, activity.Date) if err != nil { return fmt.Sprintf("%.2f %s (conv. error)", total, activity.Currency) } @@ -99,28 +106,38 @@ func formatActivityTotal(activity model.Activity, portfolioBaseCurrency string) return fmt.Sprintf("%.2f %s (~%.2f %s)", total, activity.Currency, convertedTotal, portfolioBaseCurrency) } -// getConvertedPrice returns the converted price or original if conversion fails +// getConvertedPrice returns the converted price using historical exchange rate or original if conversion fails func getConvertedPrice(activity model.Activity, portfolioBaseCurrency string) string { if activity.Currency == portfolioBaseCurrency { return "" } - convertedPrice, err := util.ConvertCurrency(activity.Price, activity.Currency, portfolioBaseCurrency) + // Use historical exchange rate for the transaction date + convertedPrice, isHistorical, err := util.GetExchangeRateWithFallback(activity.Currency, portfolioBaseCurrency, activity.Date) if err != nil { return "Conv. Error" } - return fmt.Sprintf("%.2f %s", convertedPrice, portfolioBaseCurrency) + finalPrice := activity.Price * convertedPrice + + // Add indicator if fallback (current) rate was used instead of historical + if !isHistorical { + return fmt.Sprintf("%.2f %s*", finalPrice, portfolioBaseCurrency) + } + + return fmt.Sprintf("%.2f %s", finalPrice, portfolioBaseCurrency) } -// getConvertedTotal returns the converted total or original if conversion fails +// getConvertedTotal returns the converted total using historical exchange rate or original if conversion fails func getConvertedTotal(activity model.Activity, portfolioBaseCurrency string) string { if activity.Currency == portfolioBaseCurrency { return "" } total := activity.Amount * activity.Price - convertedTotal, err := util.ConvertCurrency(total, activity.Currency, portfolioBaseCurrency) + + // Use historical exchange rate for the transaction date + convertedTotal, err := util.ConvertCurrencyHistorical(total, activity.Currency, portfolioBaseCurrency, activity.Date) if err != nil { return "Conv. Error" } @@ -128,15 +145,47 @@ func getConvertedTotal(activity model.Activity, portfolioBaseCurrency string) st return fmt.Sprintf("%.2f %s", convertedTotal, portfolioBaseCurrency) } -// formatTotalInvestedWithConversion formats total invested with currency info +// getConvertedTotalWithFallbackInfo returns the converted total with information about rate source +func getConvertedTotalWithFallbackInfo(activity model.Activity, portfolioBaseCurrency string) string { + if activity.Currency == portfolioBaseCurrency { + return "" + } + + total := activity.Amount * activity.Price + + // Use historical exchange rate with fallback information + rate, isHistorical, err := util.GetExchangeRateWithFallback(activity.Currency, portfolioBaseCurrency, activity.Date) + if err != nil { + return "Conv. Error" + } + + convertedTotal := total * rate + + // Add indicator if fallback (current) rate was used instead of historical + if !isHistorical { + return fmt.Sprintf("%.2f %s*", convertedTotal, portfolioBaseCurrency) + } + + return fmt.Sprintf("%.2f %s", convertedTotal, portfolioBaseCurrency) +} + +// formatTotalInvestedWithConversion formats total invested with currency info and conversion indicators func formatTotalInvestedWithConversion(activities []model.Activity, baseCurrency string) string { convertedTotal := calculateTotalInvestedInBaseCurrency(activities, baseCurrency) // Check if any activities have different currencies hasDifferentCurrencies := false + hasHistoricalConversions := false + for _, activity := range activities { if activity.Type == model.Buy && activity.Currency != baseCurrency { hasDifferentCurrencies = true + + // Check if we can get historical rate for this transaction + _, isHistorical, err := util.GetExchangeRateWithFallback(activity.Currency, baseCurrency, activity.Date) + if err == nil && isHistorical { + hasHistoricalConversions = true + } break } } @@ -145,5 +194,120 @@ func formatTotalInvestedWithConversion(activities []model.Activity, baseCurrency return fmt.Sprintf("%.2f %s", convertedTotal, baseCurrency) } + if hasHistoricalConversions { + return fmt.Sprintf("%.2f %s (historical rates)", convertedTotal, baseCurrency) + } + return fmt.Sprintf("%.2f %s (converted)", convertedTotal, baseCurrency) } + +// calculateTotalInvestedByDate calculates total invested up to a specific date +func calculateTotalInvestedByDate(activities []model.Activity, baseCurrency string, endDate time.Time) float64 { + var total float64 = 0 + for _, activity := range activities { + if activity.Type == model.Buy && activity.Date.Before(endDate.AddDate(0, 0, 1)) { // Include transactions on endDate + activityTotal := activity.Amount * activity.Price + + if activity.Currency == baseCurrency { + total += activityTotal + } else { + // Use historical exchange rate for the transaction date + convertedTotal, err := util.ConvertCurrencyHistorical(activityTotal, activity.Currency, baseCurrency, activity.Date) + if err != nil { + // If historical conversion fails, try current rate as fallback + fallbackTotal, fallbackErr := util.ConvertCurrency(activityTotal, activity.Currency, baseCurrency) + if fallbackErr != nil { + // If all conversions fail, add original amount (last resort) + total += activityTotal + } else { + total += fallbackTotal + } + } else { + total += convertedTotal + } + } + } + } + return total +} + +// getConversionInfo returns information about currency conversions used in the portfolio +func getConversionInfo(activities []model.Activity, baseCurrency string) map[string]interface{} { + info := map[string]interface{}{ + "hasDifferentCurrencies": false, + "historicalConversions": 0, + "fallbackConversions": 0, + "errorConversions": 0, + "currencies": make(map[string]int), + } + + currencies := make(map[string]int) + historicalCount := 0 + fallbackCount := 0 + errorCount := 0 + + for _, activity := range activities { + if activity.Type == model.Buy { + currencies[activity.Currency]++ + + if activity.Currency != baseCurrency { + info["hasDifferentCurrencies"] = true + + // Check conversion status + _, isHistorical, err := util.GetExchangeRateWithFallback(activity.Currency, baseCurrency, activity.Date) + if err != nil { + errorCount++ + } else if isHistorical { + historicalCount++ + } else { + fallbackCount++ + } + } + } + } + + info["historicalConversions"] = historicalCount + info["fallbackConversions"] = fallbackCount + info["errorConversions"] = errorCount + info["currencies"] = currencies + + return info +} + +// formatCurrencyWithConversionNote formats currency amount with appropriate conversion notes +func formatCurrencyWithConversionNote(amount float64, fromCurrency, toCurrency string, transactionDate time.Time) string { + if fromCurrency == toCurrency { + return util.FormatCurrencyWithSymbol(amount, toCurrency) + } + + rate, isHistorical, err := util.GetExchangeRateWithFallback(fromCurrency, toCurrency, transactionDate) + if err != nil { + return fmt.Sprintf("%.2f %s (conv. error)", amount, fromCurrency) + } + + convertedAmount := amount * rate + + if isHistorical { + return fmt.Sprintf("%.2f %s (historical rate)", convertedAmount, toCurrency) + } + + return fmt.Sprintf("%.2f %s (current rate)", convertedAmount, toCurrency) +} + +// getActivityConversionStatus returns the conversion status for a specific activity +func getActivityConversionStatus(activity model.Activity, portfolioBaseCurrency string) string { + if activity.Currency == portfolioBaseCurrency { + return "same_currency" + } + + _, isHistorical, err := util.GetExchangeRateWithFallback(activity.Currency, portfolioBaseCurrency, activity.Date) + if err != nil { + return "error" + } + + if isHistorical { + return "historical" + } + + return "fallback" +} diff --git a/internal/web/templates/portfolio-detail.templ b/internal/web/templates/portfolio-detail.templ index 6a2c35f..7c06e3c 100644 --- a/internal/web/templates/portfolio-detail.templ +++ b/internal/web/templates/portfolio-detail.templ @@ -128,7 +128,7 @@ templ PortfolioDetailContent(portfolio model.Portfolio) { { fmt.Sprintf("%.2f %s", activity.Amount * activity.Price, activity.Currency) } if activity.Currency != portfolio.BaseCurrency { - { getConvertedTotal(activity, portfolio.BaseCurrency) } + { getConvertedTotalWithFallbackInfo(activity, portfolio.BaseCurrency) } } else { - } @@ -194,10 +194,10 @@ templ PortfolioDetailContent(portfolio model.Portfolio) {
-
Währungsumrechnung
+
Historische Währungsumrechnung
- Transaktionen in anderen Währungen werden automatisch in { portfolio.BaseCurrency } umgerechnet. - Wechselkurse werden stündlich aktualisiert. + Transaktionen werden mit den historischen Wechselkursen vom Transaktionsdatum in { portfolio.BaseCurrency } umgerechnet. + Bei nicht verfügbaren historischen Kursen werden aktuelle Kurse verwendet (markiert mit *).
@@ -408,6 +408,9 @@ templ PortfolioSummary(activities []model.Activity, currency string) {
{ formatTotalInvestedWithConversion(activities, currency) }
+
+ * = Aktueller Kurs verwendet (historischer Kurs nicht verfügbar) +
Letzter Kauf
diff --git a/internal/web/templates/portfolio-detail_templ.go b/internal/web/templates/portfolio-detail_templ.go index 836977d..0226b73 100644 --- a/internal/web/templates/portfolio-detail_templ.go +++ b/internal/web/templates/portfolio-detail_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.2.778 +// templ: version: v0.3.906 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -35,33 +35,33 @@ func PortfolioDetailContent(portfolio model.Portfolio) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 14, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 14, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.BaseCurrency) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 14, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 14, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(")

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ")

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -69,175 +69,179 @@ func PortfolioDetailContent(portfolio model.Portfolio) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 17, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 17, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Erstellt am ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Erstellt am ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.CreatedAt.Format("02.01.2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 19, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 19, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Positionen

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Positionen

WertpapierAnzahlØ EinkaufspreisAktueller Wert+/- Gesamt
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(portfolio.Activities) > 0 { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
WertpapierAnzahlØ EinkaufspreisAktueller Wert+/- Gesamt
Position calculation will be implemented here
Position calculation will be implemented here
Keine Positionen vorhanden. Fügen Sie Ihre erste Transaktion hinzu.
Keine Positionen vorhanden. Fügen Sie Ihre erste Transaktion hinzu.

Letzte Transaktionen

DatumTypWertpapierAnzahlPreisPreis (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Letzte Transaktionen

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(portfolio.Activities) > 0 { for i, activity := range portfolio.Activities { if i < 10 { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Löschen") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
DatumTypWertpapierAnzahlPreisPreis (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.BaseCurrency) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 91, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 91, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(")GesamtGesamt (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ")GesamtGesamt (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.BaseCurrency) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 93, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 93, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(")
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(activity.Date.Format("02.01.2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 102, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 102, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if activity.Type == "BUY" { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Kauf") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Kauf") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if activity.Type == "SELL" { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Verkauf") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Verkauf") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if activity.Type == "DIVIDEND" { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Dividende") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Dividende") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(string(activity.Type)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 111, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 111, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-decoration-none\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(activity.Stock) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 116, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 116, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.3f", activity.Amount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 119, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 119, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f %s", activity.Price, activity.Currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 120, Col: 76} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 120, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -245,233 +249,233 @@ func PortfolioDetailContent(portfolio model.Portfolio) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(getConvertedPrice(activity, portfolio.BaseCurrency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 123, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 123, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("-") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "-") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f %s", activity.Amount*activity.Price, activity.Currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 128, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 128, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if activity.Currency != portfolio.BaseCurrency { var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(getConvertedTotal(activity, portfolio.BaseCurrency)) + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(getConvertedTotalWithFallbackInfo(activity, portfolio.BaseCurrency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 131, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 131, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("-") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "-") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Keine Transaktionen vorhanden
Keine Transaktionen vorhanden
Währungsumrechnung
Transaktionen in anderen Währungen werden automatisch in ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
Historische Währungsumrechnung
Transaktionen werden mit den historischen Wechselkursen vom Transaktionsdatum in ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.BaseCurrency) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 199, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 199, Col: 114} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" umgerechnet. Wechselkurse werden stündlich aktualisiert.

Portfolio-Zusammenfassung

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " umgerechnet. Bei nicht verfügbaren historischen Kursen werden aktuelle Kurse verwendet (markiert mit *).

Portfolio-Zusammenfassung

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -479,76 +483,76 @@ func PortfolioDetailContent(portfolio model.Portfolio) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Allokation

Allokations-Chart wird hier implementiert

Transaktion hinzufügen

Allokation

Allokations-Chart wird hier implementiert

Transaktion hinzufügen
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.BaseCurrency) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 258, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 258, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Transaktion bearbeiten
Transaktion bearbeiten
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(portfolio.BaseCurrency) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 311, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 311, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Transaktion löschen

Möchten Sie diese Transaktion wirklich löschen?

Diese Aktion kann nicht rückgängig gemacht werden.
Transaktion löschen

Möchten Sie diese Transaktion wirklich löschen?

Diese Aktion kann nicht rückgängig gemacht werden.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - return templ_7745c5c3_Err + return nil }) } @@ -574,50 +578,50 @@ func PortfolioSummary(activities []model.Activity, currency string) templ.Compon templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Anzahl Transaktionen
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
Anzahl Transaktionen
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(activities))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 404, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 404, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Gesamtwert investiert
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
Gesamtwert investiert
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(formatTotalInvestedWithConversion(activities, currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 409, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 409, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Letzter Kauf
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
* = Aktueller Kurs verwendet (historischer Kurs nicht verfügbar)
Letzter Kauf
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(getLastBuyDate(activities)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/portfolio-detail.templ`, Line: 415, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `portfolio-detail.templ`, Line: 418, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - return templ_7745c5c3_Err + return nil }) } @@ -646,7 +650,7 @@ func PortfolioDetail(authenticated bool, username string, portfolio model.Portfo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - return templ_7745c5c3_Err + return nil }) }