File History: src/main/java/com/paymentlink/service/CurrencyService.java

← View file content

File Content at Commit 188fc92

1 package com.paymentlink.service;
2
3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper;
5 import com.github.benmanes.caffeine.cache.Cache;
6 import com.github.benmanes.caffeine.cache.Caffeine;
7 import com.paymentlink.model.entity.CurrencyRefreshLock;
8 import com.paymentlink.model.entity.ExchangeRate;
9 import com.paymentlink.repository.CurrencyRefreshLockRepository;
10 import com.paymentlink.repository.ExchangeRateRepository;
11 import jakarta.annotation.PostConstruct;
12 import org.slf4j.Logger;
13 import org.slf4j.LoggerFactory;
14 import org.springframework.beans.factory.annotation.Value;
15 import org.springframework.scheduling.annotation.Scheduled;
16 import org.springframework.stereotype.Service;
17 import org.springframework.transaction.annotation.Transactional;
18 import org.springframework.web.client.RestTemplate;
19
20 import java.time.LocalDateTime;
21 import java.util.*;
22 import java.util.concurrent.TimeUnit;
23
24 @Service
25 public class CurrencyService {
26
27 private static final Logger logger = LoggerFactory.getLogger(CurrencyService.class);
28 private static final String LOCK_NAME = "EXCHANGE_RATE_REFRESH";
29
30 private final ExchangeRateRepository exchangeRateRepository;
31 private final CurrencyRefreshLockRepository lockRepository;
32 private final RestTemplate restTemplate;
33 private final ObjectMapper objectMapper;
34
35 private Cache<String, Double> rateCache;
36
37 @Value("${currency.api.url:https://api.exchangerate-api.com/v4/latest/USD}")
38 private String apiUrl;
39
40 @Value("${currency.api.timeout:5000}")
41 private int apiTimeout;
42
43 @Value("${currency.cache.ttl:300000}")
44 private long cacheTtl;
45
46 @Value("${currency.db.ttl:3600000}")
47 private long dbTtl;
48
49 @Value("${currency.base.currency:USD}")
50 private String baseCurrency;
51
52 @Value("${currency.refresh.lock.timeout:300000}")
53 private long lockTimeout;
54
55 @Value("${instance.id:unknown}")
56 private String instanceId;
57
58 // Stripe supported currencies (subset)
59 private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
60 "USD", "EUR", "GBP", "CAD", "AUD", "JPY", "MXN", "BRL", "INR", "CNY",
61 "SEK", "NOK", "DKK", "CHF", "NZD", "SGD", "HKD", "KRW", "TRY", "ZAR"
62 );
63
64 public CurrencyService(ExchangeRateRepository exchangeRateRepository,
65 CurrencyRefreshLockRepository lockRepository) {
66 this.exchangeRateRepository = exchangeRateRepository;
67 this.lockRepository = lockRepository;
68 this.restTemplate = new RestTemplate();
69 this.objectMapper = new ObjectMapper();
70 }
71
72 @PostConstruct
73 public void init() {
74 // Initialize local cache (5 min TTL)
75 rateCache = Caffeine.newBuilder()
76 .maximumSize(100)
77 .expireAfterWrite(cacheTtl, TimeUnit.MILLISECONDS)
78 .build();
79
80 logger.info("CurrencyService initialized for instance: {} with cache TTL: {}ms",
81 instanceId, cacheTtl);
82 }
83
84 /**
85 * Convert price from one currency to another
86 */
87 public Long convertPrice(Long priceInCents, String fromCurrency, String toCurrency) {
88 if (fromCurrency.equalsIgnoreCase(toCurrency)) {
89 return priceInCents;
90 }
91
92 Double rate = getRate(fromCurrency, toCurrency);
93 if (rate == null) {
94 logger.warn("No exchange rate found for {}/{}, returning original price", fromCurrency, toCurrency);
95 return priceInCents;
96 }
97
98 long converted = Math.round(priceInCents * rate);
99 logger.debug("Converted {} {} to {} {} (rate: {})", priceInCents, fromCurrency, converted, toCurrency, rate);
100 return converted;
101 }
102
103 /**
104 * Get exchange rate (local cache → DB → fallback)
105 */
106 public Double getRate(String fromCurrency, String toCurrency) {
107 if (fromCurrency.equalsIgnoreCase(toCurrency)) {
108 return 1.0;
109 }
110
111 String cacheKey = buildCacheKey(fromCurrency, toCurrency);
112
113 // Check local cache first
114 Double cached = rateCache.getIfPresent(cacheKey);
115 if (cached != null) {
116 return cached;
117 }
118
119 // Check database
120 Optional<ExchangeRate> rateOpt = exchangeRateRepository.findValidRate(
121 fromCurrency.toUpperCase(),
122 toCurrency.toUpperCase(),
123 LocalDateTime.now()
124 );
125
126 if (rateOpt.isPresent()) {
127 Double rate = rateOpt.get().getRate();
128 rateCache.put(cacheKey, rate);
129 return rate;
130 }
131
132 // Fallback to static rates
133 logger.warn("Using fallback rate for {}/{}", fromCurrency, toCurrency);
134 return getFallbackRate(fromCurrency, toCurrency);
135 }
136
137 /**
138 * Scheduled task to refresh exchange rates (runs every hour)
139 * Uses distributed lock to ensure only one instance refreshes at a time
140 */
141 @Scheduled(fixedRate = 3600000) // Every hour
142 @Transactional
143 public void scheduledRefresh() {
144 if (!acquireRefreshLock()) {
145 logger.info("Another instance is refreshing rates, skipping");
146 return;
147 }
148
149 try {
150 logger.info("Instance {} acquired lock, refreshing exchange rates", instanceId);
151 refreshExchangeRates();
152 } catch (Exception e) {
153 logger.error("Failed to refresh exchange rates", e);
154 } finally {
155 releaseRefreshLock();
156 }
157 }
158
159 /**
160 * Manually trigger exchange rate refresh
161 */
162 @Transactional
163 public void manualRefresh() {
164 if (!acquireRefreshLock()) {
165 throw new IllegalStateException("Another instance is currently refreshing rates");
166 }
167
168 try {
169 refreshExchangeRates();
170 } finally {
171 releaseRefreshLock();
172 }
173 }
174
175 /**
176 * Acquire distributed lock for rate refresh
177 */
178 private boolean acquireRefreshLock() {
179 try {
180 Optional<CurrencyRefreshLock> existingLock = lockRepository.findByLockName(LOCK_NAME);
181
182 if (existingLock.isPresent()) {
183 CurrencyRefreshLock lock = existingLock.get();
184
185 // Check if lock has expired
186 if (lock.getExpiresAt().isAfter(LocalDateTime.now())) {
187 logger.debug("Lock held by instance: {}", lock.getLockedByInstance());
188 return false;
189 }
190
191 // Lock expired, take it over
192 logger.info("Taking over expired lock from instance: {}", lock.getLockedByInstance());
193 lock.setLockedAt(LocalDateTime.now());
194 lock.setExpiresAt(LocalDateTime.now().plusSeconds(lockTimeout / 1000));
195 lock.setLockedByInstance(instanceId);
196 lockRepository.save(lock);
197 return true;
198 }
199
200 // No existing lock, create new one
201 CurrencyRefreshLock newLock = CurrencyRefreshLock.builder()
202 .lockName(LOCK_NAME)
203 .lockedAt(LocalDateTime.now())
204 .expiresAt(LocalDateTime.now().plusSeconds(lockTimeout / 1000))
205 .lockedByInstance(instanceId)
206 .build();
207 lockRepository.save(newLock);
208 return true;
209
210 } catch (Exception e) {
211 logger.error("Failed to acquire lock", e);
212 return false;
213 }
214 }
215
216 /**
217 * Release distributed lock
218 */
219 private void releaseRefreshLock() {
220 try {
221 Optional<CurrencyRefreshLock> lockOpt = lockRepository.findByLockName(LOCK_NAME);
222 if (lockOpt.isPresent()) {
223 CurrencyRefreshLock lock = lockOpt.get();
224 if (instanceId.equals(lock.getLockedByInstance())) {
225 lockRepository.delete(lock);
226 logger.info("Released lock by instance: {}", instanceId);
227 }
228 }
229 } catch (Exception e) {
230 logger.error("Failed to release lock", e);
231 }
232 }
233
234 /**
235 * Fetch rates from external API and update database
236 */
237 private void refreshExchangeRates() {
238 try {
239 logger.info("Fetching exchange rates from API: {}", apiUrl);
240 String response = restTemplate.getForObject(apiUrl, String.class);
241
242 if (response == null) {
243 logger.error("Empty response from currency API");
244 return;
245 }
246
247 JsonNode root = objectMapper.readTree(response);
248 JsonNode rates = root.get("rates");
249
250 if (rates == null) {
251 logger.error("No rates found in API response");
252 return;
253 }
254
255 LocalDateTime now = LocalDateTime.now();
256 LocalDateTime expiresAt = now.plusSeconds(dbTtl / 1000);
257 int updated = 0;
258
259 // Update rates in database
260 Iterator<Map.Entry<String, JsonNode>> fields = rates.fields();
261 while (fields.hasNext()) {
262 Map.Entry<String, JsonNode> entry = fields.next();
263 String toCurrency = entry.getKey();
264 double rate = entry.getValue().asDouble();
265
266 // Only save supported currencies
267 if (SUPPORTED_CURRENCIES.contains(toCurrency)) {
268 saveOrUpdateRate(baseCurrency, toCurrency, rate, now, expiresAt);
269 updated++;
270 }
271 }
272
273 // Refresh local cache from DB
274 reloadLocalCache();
275
276 logger.info("Successfully refreshed {} exchange rates", updated);
277
278 } catch (Exception e) {
279 logger.error("Failed to fetch exchange rates from API", e);
280 }
281 }
282
283 /**
284 * Save or update exchange rate in database
285 */
286 private void saveOrUpdateRate(String from, String to, double rate,
287 LocalDateTime fetchedAt, LocalDateTime expiresAt) {
288 Optional<ExchangeRate> existingOpt = exchangeRateRepository.findByFromCurrencyAndToCurrency(from, to);
289
290 ExchangeRate exchangeRate;
291 if (existingOpt.isPresent()) {
292 exchangeRate = existingOpt.get();
293 exchangeRate.setRate(rate);
294 exchangeRate.setFetchedAt(fetchedAt);
295 exchangeRate.setExpiresAt(expiresAt);
296 } else {
297 exchangeRate = ExchangeRate.builder()
298 .fromCurrency(from)
299 .toCurrency(to)
300 .rate(rate)
301 .fetchedAt(fetchedAt)
302 .expiresAt(expiresAt)
303 .build();
304 }
305
306 exchangeRateRepository.save(exchangeRate);
307 }
308
309 /**
310 * Reload local cache from database
311 */
312 private void reloadLocalCache() {
313 rateCache.invalidateAll();
314 List<ExchangeRate> validRates = exchangeRateRepository.findAll();
315
316 for (ExchangeRate rate : validRates) {
317 if (rate.getExpiresAt().isAfter(LocalDateTime.now())) {
318 String cacheKey = buildCacheKey(rate.getFromCurrency(), rate.getToCurrency());
319 rateCache.put(cacheKey, rate.getRate());
320 }
321 }
322
323 logger.info("Reloaded {} rates into local cache", validRates.size());
324 }
325
326 /**
327 * Get fallback static rates (hardcoded)
328 */
329 private Double getFallbackRate(String from, String to) {
330 Map<String, Map<String, Double>> fallbackRates = new HashMap<>();
331
332 // USD to other currencies (approximate rates)
333 Map<String, Double> usdRates = new HashMap<>();
334 usdRates.put("EUR", 0.92);
335 usdRates.put("GBP", 0.79);
336 usdRates.put("CAD", 1.36);
337 usdRates.put("AUD", 1.53);
338 usdRates.put("JPY", 149.0);
339 usdRates.put("MXN", 17.0);
340 usdRates.put("BRL", 5.0);
341 usdRates.put("INR", 83.0);
342 usdRates.put("CNY", 7.2);
343 fallbackRates.put("USD", usdRates);
344
345 String fromUpper = from.toUpperCase();
346 String toUpper = to.toUpperCase();
347
348 if (fallbackRates.containsKey(fromUpper) && fallbackRates.get(fromUpper).containsKey(toUpper)) {
349 return fallbackRates.get(fromUpper).get(toUpper);
350 }
351
352 // If no fallback found, return 1.0 (no conversion)
353 return 1.0;
354 }
355
356 /**
357 * Check if currency is supported by Stripe
358 */
359 public boolean isCurrencySupported(String currency) {
360 return SUPPORTED_CURRENCIES.contains(currency.toUpperCase());
361 }
362
363 /**
364 * Get all supported currencies
365 */
366 public Set<String> getSupportedCurrencies() {
367 return new HashSet<>(SUPPORTED_CURRENCIES);
368 }
369
370 /**
371 * Build cache key for rate
372 */
373 private String buildCacheKey(String from, String to) {
374 return from.toUpperCase() + ":" + to.toUpperCase();
375 }
376
377 /**
378 * Clear local cache
379 */
380 public void clearCache() {
381 rateCache.invalidateAll();
382 logger.info("Currency rate cache cleared");
383 }
384
385 /**
386 * Get all current rates from database
387 */
388 public List<ExchangeRate> getAllRates() {
389 return exchangeRateRepository.findAll();
390 }
391 }
392

Commits

Commit Author Date Message File SHA Actions
f0438c2 <f69e50@finnacloud.com> 1766443042 +0300 12/22/2025, 10:37:22 PM increment once more 8dc727e View
188fc92 <f69e50@finnacloud.com> 1766442998 +0300 12/22/2025, 10:36:38 PM increment 8dc727e Hide
4617f76 <f69e50@finnacloud.com> 1766442953 +0300 12/22/2025, 10:35:53 PM rename branch from main to master oops 8dc727e View
e6d1548 <f69e50@finnacloud.com> 1766442769 +0300 12/22/2025, 10:32:49 PM add initial test workflow file 8dc727e View
9c24ca4 <f69e50@finnacloud.com> 1766442705 +0300 12/22/2025, 10:31:45 PM add CI configuration and test script for Jenkins build 8dc727e View
690c1f6 <f69e50@finnacloud.com> 1766368110 +0300 12/22/2025, 1:48:30 AM initialize backend structure with controllers, DTOs, and configuration files 8dc727e View