package com.paymentlink.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.paymentlink.model.entity.CurrencyRefreshLock; import com.paymentlink.model.entity.ExchangeRate; import com.paymentlink.repository.CurrencyRefreshLockRepository; import com.paymentlink.repository.ExchangeRateRepository; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.TimeUnit; @Service public class CurrencyService { private static final Logger logger = LoggerFactory.getLogger(CurrencyService.class); private static final String LOCK_NAME = "EXCHANGE_RATE_REFRESH"; private final ExchangeRateRepository exchangeRateRepository; private final CurrencyRefreshLockRepository lockRepository; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private Cache rateCache; @Value("${currency.api.url:https://api.exchangerate-api.com/v4/latest/USD}") private String apiUrl; @Value("${currency.api.timeout:5000}") private int apiTimeout; @Value("${currency.cache.ttl:300000}") private long cacheTtl; @Value("${currency.db.ttl:3600000}") private long dbTtl; @Value("${currency.base.currency:USD}") private String baseCurrency; @Value("${currency.refresh.lock.timeout:300000}") private long lockTimeout; @Value("${instance.id:unknown}") private String instanceId; // Stripe supported currencies (subset) private static final Set SUPPORTED_CURRENCIES = Set.of( "USD", "EUR", "GBP", "CAD", "AUD", "JPY", "MXN", "BRL", "INR", "CNY", "SEK", "NOK", "DKK", "CHF", "NZD", "SGD", "HKD", "KRW", "TRY", "ZAR" ); public CurrencyService(ExchangeRateRepository exchangeRateRepository, CurrencyRefreshLockRepository lockRepository) { this.exchangeRateRepository = exchangeRateRepository; this.lockRepository = lockRepository; this.restTemplate = new RestTemplate(); this.objectMapper = new ObjectMapper(); } @PostConstruct public void init() { // Initialize local cache (5 min TTL) rateCache = Caffeine.newBuilder() .maximumSize(100) .expireAfterWrite(cacheTtl, TimeUnit.MILLISECONDS) .build(); logger.info("CurrencyService initialized for instance: {} with cache TTL: {}ms", instanceId, cacheTtl); } /** * Convert price from one currency to another */ public Long convertPrice(Long priceInCents, String fromCurrency, String toCurrency) { if (fromCurrency.equalsIgnoreCase(toCurrency)) { return priceInCents; } Double rate = getRate(fromCurrency, toCurrency); if (rate == null) { logger.warn("No exchange rate found for {}/{}, returning original price", fromCurrency, toCurrency); return priceInCents; } long converted = Math.round(priceInCents * rate); logger.debug("Converted {} {} to {} {} (rate: {})", priceInCents, fromCurrency, converted, toCurrency, rate); return converted; } /** * Get exchange rate (local cache → DB → fallback) */ public Double getRate(String fromCurrency, String toCurrency) { if (fromCurrency.equalsIgnoreCase(toCurrency)) { return 1.0; } String cacheKey = buildCacheKey(fromCurrency, toCurrency); // Check local cache first Double cached = rateCache.getIfPresent(cacheKey); if (cached != null) { return cached; } // Check database Optional rateOpt = exchangeRateRepository.findValidRate( fromCurrency.toUpperCase(), toCurrency.toUpperCase(), LocalDateTime.now() ); if (rateOpt.isPresent()) { Double rate = rateOpt.get().getRate(); rateCache.put(cacheKey, rate); return rate; } // Fallback to static rates logger.warn("Using fallback rate for {}/{}", fromCurrency, toCurrency); return getFallbackRate(fromCurrency, toCurrency); } /** * Scheduled task to refresh exchange rates (runs every hour) * Uses distributed lock to ensure only one instance refreshes at a time */ @Scheduled(fixedRate = 3600000) // Every hour @Transactional public void scheduledRefresh() { if (!acquireRefreshLock()) { logger.info("Another instance is refreshing rates, skipping"); return; } try { logger.info("Instance {} acquired lock, refreshing exchange rates", instanceId); refreshExchangeRates(); } catch (Exception e) { logger.error("Failed to refresh exchange rates", e); } finally { releaseRefreshLock(); } } /** * Manually trigger exchange rate refresh */ @Transactional public void manualRefresh() { if (!acquireRefreshLock()) { throw new IllegalStateException("Another instance is currently refreshing rates"); } try { refreshExchangeRates(); } finally { releaseRefreshLock(); } } /** * Acquire distributed lock for rate refresh */ private boolean acquireRefreshLock() { try { Optional existingLock = lockRepository.findByLockName(LOCK_NAME); if (existingLock.isPresent()) { CurrencyRefreshLock lock = existingLock.get(); // Check if lock has expired if (lock.getExpiresAt().isAfter(LocalDateTime.now())) { logger.debug("Lock held by instance: {}", lock.getLockedByInstance()); return false; } // Lock expired, take it over logger.info("Taking over expired lock from instance: {}", lock.getLockedByInstance()); lock.setLockedAt(LocalDateTime.now()); lock.setExpiresAt(LocalDateTime.now().plusSeconds(lockTimeout / 1000)); lock.setLockedByInstance(instanceId); lockRepository.save(lock); return true; } // No existing lock, create new one CurrencyRefreshLock newLock = CurrencyRefreshLock.builder() .lockName(LOCK_NAME) .lockedAt(LocalDateTime.now()) .expiresAt(LocalDateTime.now().plusSeconds(lockTimeout / 1000)) .lockedByInstance(instanceId) .build(); lockRepository.save(newLock); return true; } catch (Exception e) { logger.error("Failed to acquire lock", e); return false; } } /** * Release distributed lock */ private void releaseRefreshLock() { try { Optional lockOpt = lockRepository.findByLockName(LOCK_NAME); if (lockOpt.isPresent()) { CurrencyRefreshLock lock = lockOpt.get(); if (instanceId.equals(lock.getLockedByInstance())) { lockRepository.delete(lock); logger.info("Released lock by instance: {}", instanceId); } } } catch (Exception e) { logger.error("Failed to release lock", e); } } /** * Fetch rates from external API and update database */ private void refreshExchangeRates() { try { logger.info("Fetching exchange rates from API: {}", apiUrl); String response = restTemplate.getForObject(apiUrl, String.class); if (response == null) { logger.error("Empty response from currency API"); return; } JsonNode root = objectMapper.readTree(response); JsonNode rates = root.get("rates"); if (rates == null) { logger.error("No rates found in API response"); return; } LocalDateTime now = LocalDateTime.now(); LocalDateTime expiresAt = now.plusSeconds(dbTtl / 1000); int updated = 0; // Update rates in database Iterator> fields = rates.fields(); while (fields.hasNext()) { Map.Entry entry = fields.next(); String toCurrency = entry.getKey(); double rate = entry.getValue().asDouble(); // Only save supported currencies if (SUPPORTED_CURRENCIES.contains(toCurrency)) { saveOrUpdateRate(baseCurrency, toCurrency, rate, now, expiresAt); updated++; } } // Refresh local cache from DB reloadLocalCache(); logger.info("Successfully refreshed {} exchange rates", updated); } catch (Exception e) { logger.error("Failed to fetch exchange rates from API", e); } } /** * Save or update exchange rate in database */ private void saveOrUpdateRate(String from, String to, double rate, LocalDateTime fetchedAt, LocalDateTime expiresAt) { Optional existingOpt = exchangeRateRepository.findByFromCurrencyAndToCurrency(from, to); ExchangeRate exchangeRate; if (existingOpt.isPresent()) { exchangeRate = existingOpt.get(); exchangeRate.setRate(rate); exchangeRate.setFetchedAt(fetchedAt); exchangeRate.setExpiresAt(expiresAt); } else { exchangeRate = ExchangeRate.builder() .fromCurrency(from) .toCurrency(to) .rate(rate) .fetchedAt(fetchedAt) .expiresAt(expiresAt) .build(); } exchangeRateRepository.save(exchangeRate); } /** * Reload local cache from database */ private void reloadLocalCache() { rateCache.invalidateAll(); List validRates = exchangeRateRepository.findAll(); for (ExchangeRate rate : validRates) { if (rate.getExpiresAt().isAfter(LocalDateTime.now())) { String cacheKey = buildCacheKey(rate.getFromCurrency(), rate.getToCurrency()); rateCache.put(cacheKey, rate.getRate()); } } logger.info("Reloaded {} rates into local cache", validRates.size()); } /** * Get fallback static rates (hardcoded) */ private Double getFallbackRate(String from, String to) { Map> fallbackRates = new HashMap<>(); // USD to other currencies (approximate rates) Map usdRates = new HashMap<>(); usdRates.put("EUR", 0.92); usdRates.put("GBP", 0.79); usdRates.put("CAD", 1.36); usdRates.put("AUD", 1.53); usdRates.put("JPY", 149.0); usdRates.put("MXN", 17.0); usdRates.put("BRL", 5.0); usdRates.put("INR", 83.0); usdRates.put("CNY", 7.2); fallbackRates.put("USD", usdRates); String fromUpper = from.toUpperCase(); String toUpper = to.toUpperCase(); if (fallbackRates.containsKey(fromUpper) && fallbackRates.get(fromUpper).containsKey(toUpper)) { return fallbackRates.get(fromUpper).get(toUpper); } // If no fallback found, return 1.0 (no conversion) return 1.0; } /** * Check if currency is supported by Stripe */ public boolean isCurrencySupported(String currency) { return SUPPORTED_CURRENCIES.contains(currency.toUpperCase()); } /** * Get all supported currencies */ public Set getSupportedCurrencies() { return new HashSet<>(SUPPORTED_CURRENCIES); } /** * Build cache key for rate */ private String buildCacheKey(String from, String to) { return from.toUpperCase() + ":" + to.toUpperCase(); } /** * Clear local cache */ public void clearCache() { rateCache.invalidateAll(); logger.info("Currency rate cache cleared"); } /** * Get all current rates from database */ public List getAllRates() { return exchangeRateRepository.findAll(); } }