package service import ( "context" "errors" "fmt" "log" "time" "accounting-app/internal/cache" "accounting-app/pkg/utils" ) // Error definitions for exchange rate service v2 var ( ErrCurrencyNotSupported = errors.New("currency not supported") ErrRateNotFound = errors.New("exchange rate not found") ErrAPIUnavailable = errors.New("external API unavailable") ErrInvalidConversionAmount = errors.New("invalid conversion amount") ErrSyncFailed = errors.New("sync failed") ) // ExchangeRateDTO represents exchange rate data transfer object type ExchangeRateDTO struct { Currency string `json:"currency"` CurrencyName string `json:"currency_name"` Symbol string `json:"symbol"` Rate float64 `json:"rate"` UpdatedAt time.Time `json:"updated_at"` } // ConversionResultDTO represents currency conversion result type ConversionResultDTO struct { OriginalAmount float64 `json:"original_amount"` FromCurrency string `json:"from_currency"` ToCurrency string `json:"to_currency"` ConvertedAmount float64 `json:"converted_amount"` RateUsed float64 `json:"rate_used"` ConvertedAt time.Time `json:"converted_at"` } // SyncResultDTO represents sync operation result type SyncResultDTO struct { Message string `json:"message"` RatesUpdated int `json:"rates_updated"` SyncTime time.Time `json:"sync_time"` } // CurrencyMetadata contains display information for a currency type CurrencyMetadata struct { Name string Symbol string } // currencyMetadataMap maps currency codes to their metadata // Extended to support all currencies from YunAPI var currencyMetadataMap = map[string]CurrencyMetadata{ "CNY": {Name: "人民币", Symbol: "¥"}, "USD": {Name: "美元", Symbol: "$"}, "EUR": {Name: "欧元", Symbol: "€"}, "JPY": {Name: "日元", Symbol: "¥"}, "GBP": {Name: "英镑", Symbol: "£"}, "HKD": {Name: "港币", Symbol: "HK$"}, "AUD": {Name: "澳元", Symbol: "A$"}, "CAD": {Name: "加元", Symbol: "C$"}, "CHF": {Name: "瑞士法郎", Symbol: "CHF"}, "SGD": {Name: "新加坡元", Symbol: "S$"}, "THB": {Name: "泰铢", Symbol: "฿"}, "KRW": {Name: "韩元", Symbol: "₩"}, "AED": {Name: "阿联酋迪拉姆", Symbol: "د.إ"}, "BND": {Name: "文莱元", Symbol: "B$"}, "BRL": {Name: "巴西雷亚尔", Symbol: "R$"}, "CZK": {Name: "捷克克朗", Symbol: "Kč"}, "DKK": {Name: "丹麦克朗", Symbol: "kr"}, "HUF": {Name: "匈牙利福林", Symbol: "Ft"}, "IDR": {Name: "印尼盾", Symbol: "Rp"}, "ILS": {Name: "以色列新谢克尔", Symbol: "₪"}, "INR": {Name: "印度卢比", Symbol: "₹"}, "KHR": {Name: "柬埔寨瑞尔", Symbol: "៛"}, "KWD": {Name: "科威特第纳尔", Symbol: "د.ك"}, "MNT": {Name: "蒙古图格里克", Symbol: "₮"}, "MOP": {Name: "澳门元", Symbol: "MOP$"}, "MXN": {Name: "墨西哥比索", Symbol: "Mex$"}, "NOK": {Name: "挪威克朗", Symbol: "kr"}, "NPR": {Name: "尼泊尔卢比", Symbol: "₨"}, "NZD": {Name: "新西兰元", Symbol: "NZ$"}, "PHP": {Name: "菲律宾比索", Symbol: "₱"}, "PKR": {Name: "巴基斯坦卢比", Symbol: "₨"}, "QAR": {Name: "卡塔尔里亚尔", Symbol: "﷼"}, "RUB": {Name: "俄罗斯卢布", Symbol: "₽"}, "SAR": {Name: "沙特里亚尔", Symbol: "﷼"}, "SEK": {Name: "瑞典克朗", Symbol: "kr"}, "TRY": {Name: "土耳其里拉", Symbol: "₺"}, "TWD": {Name: "新台币", Symbol: "NT$"}, "VND": {Name: "越南盾", Symbol: "₫"}, "ZAR": {Name: "南非兰特", Symbol: "R"}, } // GetCurrencyMetadata returns metadata for a currency code func GetCurrencyMetadata(currency string) CurrencyMetadata { if meta, ok := currencyMetadataMap[currency]; ok { return meta } // Return default metadata for unknown currencies return CurrencyMetadata{Name: currency, Symbol: currency} } // IsCurrencySupported checks if a currency is supported func IsCurrencySupported(currency string) bool { _, ok := currencyMetadataMap[currency] return ok } // ExchangeRateServiceV2 provides exchange rate business logic with Redis caching type ExchangeRateServiceV2 struct { cache *cache.ExchangeRateCache client *YunAPIClient } // NewExchangeRateServiceV2 creates a new ExchangeRateServiceV2 instance func NewExchangeRateServiceV2(cache *cache.ExchangeRateCache, client *YunAPIClient) *ExchangeRateServiceV2 { return &ExchangeRateServiceV2{ cache: cache, client: client, } } // GetAllRates retrieves all exchange rates with cache-first strategy // Returns rates for all currencies relative to CNY func (s *ExchangeRateServiceV2) GetAllRates(ctx context.Context) ([]ExchangeRateDTO, error) { // Try to get from cache first rates, err := s.cache.GetAll(ctx) if err != nil { log.Printf("[ExchangeRateServiceV2] Cache error: %v, falling back to API", err) } // If cache hit, convert to DTOs if rates != nil && len(rates) > 0 { log.Printf("[ExchangeRateServiceV2] Cache hit: found %d rates", len(rates)) return s.ratesToDTOs(rates), nil } // Cache miss - fetch from API log.Println("[ExchangeRateServiceV2] Cache miss, fetching from API") rates, err = s.fetchAndCacheRates(ctx) if err != nil { return nil, fmt.Errorf("failed to get rates: %w", err) } return s.ratesToDTOs(rates), nil } // GetRatesBatch retrieves multiple currencies' exchange rates in one call // More efficient than calling GetRate multiple times func (s *ExchangeRateServiceV2) GetRatesBatch(ctx context.Context, currencies []string) ([]ExchangeRateDTO, error) { if len(currencies) == 0 { return []ExchangeRateDTO{}, nil } // Validate all currencies first for _, currency := range currencies { if !IsCurrencySupported(currency) { return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, currency) } } // Try to get all from cache allRates, err := s.cache.GetAll(ctx) if err != nil || allRates == nil { // Cache miss - fetch from API allRates, err = s.fetchAndCacheRates(ctx) if err != nil { return nil, fmt.Errorf("failed to get rates: %w", err) } } // Filter requested currencies result := make([]ExchangeRateDTO, 0, len(currencies)) for _, currency := range currencies { if currency == "CNY" { result = append(result, ExchangeRateDTO{ Currency: "CNY", CurrencyName: "人民币", Symbol: "¥", Rate: 1.0, UpdatedAt: time.Now(), }) continue } if rate, ok := allRates[currency]; ok { meta := GetCurrencyMetadata(currency) result = append(result, ExchangeRateDTO{ Currency: currency, CurrencyName: meta.Name, Symbol: meta.Symbol, Rate: rate, UpdatedAt: time.Now(), }) } else { return nil, fmt.Errorf("%w: %s", ErrRateNotFound, currency) } } return result, nil } // GetRate retrieves a single currency's exchange rate with cache-first strategy // Returns the rate for the specified currency relative to CNY func (s *ExchangeRateServiceV2) GetRate(ctx context.Context, currency string) (*ExchangeRateDTO, error) { // Validate currency if !IsCurrencySupported(currency) { return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, currency) } // CNY to CNY is always 1 if currency == "CNY" { return &ExchangeRateDTO{ Currency: "CNY", CurrencyName: "人民币", Symbol: "¥", Rate: 1.0, UpdatedAt: time.Now(), }, nil } // Try to get from cache first rate, err := s.cache.Get(ctx, currency) if err == nil { log.Printf("[ExchangeRateServiceV2] Cache hit for %s: %f", currency, rate) meta := GetCurrencyMetadata(currency) return &ExchangeRateDTO{ Currency: currency, CurrencyName: meta.Name, Symbol: meta.Symbol, Rate: rate, UpdatedAt: time.Now(), }, nil } // Cache miss - try to fetch all rates from API and cache them log.Printf("[ExchangeRateServiceV2] Cache miss for %s, fetching from API", currency) rates, err := s.fetchAndCacheRates(ctx) if err != nil { // API failed - this is a critical error return nil, fmt.Errorf("failed to get rate for %s: %w", currency, err) } // Check if the requested currency exists in the fetched rates if rate, ok := rates[currency]; ok { meta := GetCurrencyMetadata(currency) return &ExchangeRateDTO{ Currency: currency, CurrencyName: meta.Name, Symbol: meta.Symbol, Rate: rate, UpdatedAt: time.Now(), }, nil } return nil, fmt.Errorf("%w: %s", ErrRateNotFound, currency) } // GetRateWithFallback retrieves a rate with multiple fallback strategies // This provides better reliability when cache or API fails func (s *ExchangeRateServiceV2) GetRateWithFallback(ctx context.Context, currency string) (*ExchangeRateDTO, error) { // Validate currency if !IsCurrencySupported(currency) { return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, currency) } // CNY to CNY is always 1 if currency == "CNY" { return &ExchangeRateDTO{ Currency: "CNY", CurrencyName: "人民币", Symbol: "¥", Rate: 1.0, UpdatedAt: time.Now(), }, nil } // Strategy 1: Try cache rate, err := s.cache.Get(ctx, currency) if err == nil { log.Printf("[ExchangeRateServiceV2] Cache hit for %s", currency) meta := GetCurrencyMetadata(currency) return &ExchangeRateDTO{ Currency: currency, CurrencyName: meta.Name, Symbol: meta.Symbol, Rate: rate, UpdatedAt: time.Now(), }, nil } // Strategy 2: Try API log.Printf("[ExchangeRateServiceV2] Cache miss for %s, trying API", currency) rates, err := s.client.FetchRates() if err == nil { // Cache the fetched rates if cacheErr := s.cache.SetAll(ctx, rates); cacheErr != nil { log.Printf("[ExchangeRateServiceV2] Warning: failed to cache rates: %v", cacheErr) } if rate, ok := rates[currency]; ok { meta := GetCurrencyMetadata(currency) return &ExchangeRateDTO{ Currency: currency, CurrencyName: meta.Name, Symbol: meta.Symbol, Rate: rate, UpdatedAt: time.Now(), }, nil } } // All strategies failed return nil, fmt.Errorf("%w: all fallback strategies failed for %s", ErrRateNotFound, currency) } // GetSyncStatus retrieves the current synchronization status func (s *ExchangeRateServiceV2) GetSyncStatus(ctx context.Context) (*cache.SyncStatus, error) { status, err := s.cache.GetSyncStatus(ctx) if err != nil { return nil, fmt.Errorf("failed to get sync status: %w", err) } // Return default status if none exists if status == nil { return &cache.SyncStatus{ LastSyncTime: time.Time{}, LastSyncStatus: "unknown", NextSyncTime: time.Time{}, RatesCount: 0, }, nil } return status, nil } // fetchAndCacheRates fetches rates from API and caches them func (s *ExchangeRateServiceV2) fetchAndCacheRates(ctx context.Context) (map[string]float64, error) { // Fetch from API using the existing client's retry logic rates, err := s.client.FetchRates() if err != nil { return nil, fmt.Errorf("%w: %v", ErrAPIUnavailable, err) } // Cache the rates if err := s.cache.SetAll(ctx, rates); err != nil { log.Printf("[ExchangeRateServiceV2] Warning: failed to cache rates: %v", err) // Don't fail the request if caching fails } // Update sync status syncStatus := &cache.SyncStatus{ LastSyncTime: time.Now(), LastSyncStatus: "success", NextSyncTime: time.Now().Add(s.cache.GetExpiration()), RatesCount: len(rates), } if err := s.cache.SetSyncStatus(ctx, syncStatus); err != nil { log.Printf("[ExchangeRateServiceV2] Warning: failed to update sync status: %v", err) } return rates, nil } // ratesToDTOs converts a map of rates to a slice of ExchangeRateDTO func (s *ExchangeRateServiceV2) ratesToDTOs(rates map[string]float64) []ExchangeRateDTO { dtos := make([]ExchangeRateDTO, 0, len(rates)) for currency, rate := range rates { meta := GetCurrencyMetadata(currency) dtos = append(dtos, ExchangeRateDTO{ Currency: currency, CurrencyName: meta.Name, Symbol: meta.Symbol, Rate: rate, UpdatedAt: time.Now(), // We don't store individual timestamps in cache }) } return dtos } // ConvertCurrency converts an amount from one currency to another // Uses CNY as the intermediate currency for conversions // Returns the conversion result with two decimal places precision func (s *ExchangeRateServiceV2) ConvertCurrency(ctx context.Context, amount float64, from, to string) (*ConversionResultDTO, error) { // Validate amount if amount < 0 { return nil, fmt.Errorf("%w: amount cannot be negative", ErrInvalidConversionAmount) } // Validate currencies if !IsCurrencySupported(from) { return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, from) } if !IsCurrencySupported(to) { return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, to) } // Handle same currency conversion (Requirement 6.3) if from == to { return &ConversionResultDTO{ OriginalAmount: amount, FromCurrency: from, ToCurrency: to, ConvertedAmount: utils.RoundToTwoDecimals(amount), RateUsed: 1.0, ConvertedAt: time.Now(), }, nil } // Get rates for conversion - try cache first, fallback to API var fromRate, toRate float64 var err error // Get all rates at once for better performance allRates, err := s.cache.GetAll(ctx) if err != nil || allRates == nil || len(allRates) == 0 { // Cache miss - fetch from API log.Println("[ExchangeRateServiceV2] Cache miss in conversion, fetching from API") allRates, err = s.fetchAndCacheRates(ctx) if err != nil { return nil, fmt.Errorf("failed to fetch rates for conversion: %w", err) } } // Get from rate if from == "CNY" { fromRate = 1.0 } else { rate, ok := allRates[from] if !ok { return nil, fmt.Errorf("%w: %s", ErrRateNotFound, from) } fromRate = rate } // Get to rate if to == "CNY" { toRate = 1.0 } else { rate, ok := allRates[to] if !ok { return nil, fmt.Errorf("%w: %s", ErrRateNotFound, to) } toRate = rate } // Calculate conversion using CNY as intermediate currency (Requirement 6.4) // Rate represents: 1 Currency = Rate CNY // Conversion formula: // - from CNY: result = amount / to_rate (CNY to target currency) // - to CNY: result = amount * from_rate (source currency to CNY) // - otherwise: result = amount * from_rate / to_rate (via CNY) var convertedAmount float64 var rateUsed float64 if from == "CNY" { // Converting from CNY to target currency // amount CNY / to_rate = result in target currency convertedAmount = amount / toRate rateUsed = 1.0 / toRate } else if to == "CNY" { // Converting from source currency to CNY // amount * from_rate = result in CNY convertedAmount = amount * fromRate rateUsed = fromRate } else { // Converting between two non-CNY currencies via CNY // amount * from_rate / to_rate = result convertedAmount = amount * fromRate / toRate rateUsed = fromRate / toRate } // Round to two decimal places (Requirement 6.5) convertedAmount = utils.RoundToTwoDecimals(convertedAmount) rateUsed = utils.RoundToTwoDecimals(rateUsed) return &ConversionResultDTO{ OriginalAmount: amount, FromCurrency: from, ToCurrency: to, ConvertedAmount: convertedAmount, RateUsed: rateUsed, ConvertedAt: time.Now(), }, nil } // GetCache returns the cache instance (for use by scheduler) func (s *ExchangeRateServiceV2) GetCache() *cache.ExchangeRateCache { return s.cache } // GetClient returns the API client instance (for use by scheduler) func (s *ExchangeRateServiceV2) GetClient() *YunAPIClient { return s.client }