Repository: folio-org/mod-finance
Pull request #288: [MODFIN-410]. Integrate alternate exchange rate provider into Exchange Rate API
Author: login: BKadirkhodjaev
Created: 2025-04-02T21:23:53Z | Merged: 2025-04-04T13:51:41Z

## Purpose

- <https://folio-org.atlassian.net/browse/MODFIN-410>

## Approach

- Add a custom JSON-based exchange rate provider that can use 3 supported API handlers
  - Treasury.gov (<https://fiscaldata.treasury.gov/api-documentation/>)
  - Convera.com (API docs TBD)
  - Currencyapi.com (<https://currencyapi.com/docs/quickstart>)
- Add a new ExchangeService to help resolve between old/default and new/custom exchange rate providers
- Update acq-models, module descriptors and current Exchange Rate API
- Fix/silence Java Money Moneta's exceptions on service startup
- Derivative of the POC: <https://github.com/folio-org/mod-orders/pull/1087> 

## Misc

- Attaching logs of Treasury.gov, Currencyapi.com and ECB (default) Exchange rate handlers under use:

![image](https://github.com/user-attachments/assets/653ff5a7-a77e-4bf4-a1e1-f2be95744f2f)



Diff:
diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index fc6ac404..e407387d 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -691,12 +691,14 @@
         {
           "methods": ["GET"],
           "pathPattern": "/finance/exchange-rate",
-          "permissionsRequired": ["finance.exchange-rate.item.get"]
+          "permissionsRequired": ["finance.exchange-rate.item.get"],
+          "modulePermissions": ["finance-storage.exchange-rate-source.item.get"]
         },
         {
           "methods": ["GET"],
           "pathPattern": "/finance/calculate-exchange",
-          "permissionsRequired": ["finance.calculate-exchange.item.get"]
+          "permissionsRequired": ["finance.calculate-exchange.item.get"],
+          "modulePermissions": ["finance-storage.exchange-rate-source.item.get"]
         }
       ]
     },
@@ -911,6 +913,10 @@
     {
       "id": "finance-storage.finance-data",
       "version":"1.0"
+    },
+    {
+      "id": "finance-storage.exchange-rate-source",
+      "version":"1.0"
     }
   ],
   "optional": [
diff --git a/ramls/acq-models b/ramls/acq-models
index 83966672..8506fbf6 160000
--- a/ramls/acq-models
+++ b/ramls/acq-models
@@ -1 +1 @@
-Subproject commit 83966672ed65be84636951f504b9137df6c2ee0f
+Subproject commit 8506fbf6a90bbac5e62ff9f86d8ab73c50e1b1ac
diff --git a/ramls/exchange.raml b/ramls/exchange.raml
index b92d5ba2..56e7b5c7 100644
--- a/ramls/exchange.raml
+++ b/ramls/exchange.raml
@@ -11,6 +11,7 @@ documentation:
 
 types:
   exchangeRate: !include acq-models/mod-finance/schemas/exchange_rate.json
+  exchangeRateSource: !include acq-models/mod-finance/schemas/exchange_rate_source.json
   errors: !include raml-util/schemas/error.schema
   currency_code:
     description: currency_code expressed as a code of currency
diff --git a/src/main/java/org/folio/config/ServicesConfiguration.java b/src/main/java/org/folio/config/ServicesConfiguration.java
index 683ec477..3e4c82ee 100644
--- a/src/main/java/org/folio/config/ServicesConfiguration.java
+++ b/src/main/java/org/folio/config/ServicesConfiguration.java
@@ -37,6 +37,8 @@
 import org.folio.services.transactions.TransactionTotalService;
 import org.springframework.context.annotation.Bean;
 
+import java.net.http.HttpClient;
+
 public class ServicesConfiguration {
 
   @Bean
@@ -232,4 +234,9 @@ FinanceDataService financeDataService(RestClient restClient, LedgerService ledge
   FinanceDataValidator financeDataValidator(FundService fundService, BudgetService budgetService) {
     return new FinanceDataValidator(fundService, budgetService);
   }
+
+  @Bean
+  HttpClient httpClient() {
+    return HttpClient.newHttpClient();
+  }
 }
diff --git a/src/main/java/org/folio/rest/impl/ExchangeApi.java b/src/main/java/org/folio/rest/impl/ExchangeApi.java
index 26e4ca86..21a52a8c 100644
--- a/src/main/java/org/folio/rest/impl/ExchangeApi.java
+++ b/src/main/java/org/folio/rest/impl/ExchangeApi.java
@@ -1,8 +1,6 @@
 package org.folio.rest.impl;
 
 import static io.vertx.core.Future.succeededFuture;
-import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
-import static org.folio.rest.util.HelperUtils.handleErrorResponse;
 
 import javax.ws.rs.core.Response;
 import java.util.Map;
@@ -10,30 +8,40 @@
 import io.vertx.core.AsyncResult;
 import io.vertx.core.Context;
 import io.vertx.core.Handler;
+import io.vertx.core.Vertx;
 import org.folio.rest.annotations.Validate;
-import org.folio.rest.helper.ExchangeHelper;
+import org.folio.rest.core.models.RequestContext;
 import org.folio.rest.jaxrs.resource.FinanceCalculateExchange;
 import org.folio.rest.jaxrs.resource.FinanceExchangeRate;
+import org.folio.services.exchange.ExchangeService;
+import org.folio.spring.SpringContextUtil;
+import org.springframework.beans.factory.annotation.Autowired;
 
-public class ExchangeApi implements FinanceExchangeRate, FinanceCalculateExchange {
+public class ExchangeApi extends BaseApi implements FinanceExchangeRate, FinanceCalculateExchange {
+
+  @Autowired
+  private ExchangeService exchangeService;
+
+  public ExchangeApi() {
+    SpringContextUtil.autowireDependencies(this, Vertx.currentContext());
+  }
 
   @Override
   @Validate
   public void getFinanceExchangeRate(String from, String to, Map<String, String> okapiHeaders,
-                                     Handler<AsyncResult<Response>> asyncResultHandler, Context vertxContext) {
-    ExchangeHelper helper = new ExchangeHelper(vertxContext);
-    vertxContext.executeBlocking(() -> helper.getExchangeRate(from, to))
-      .onSuccess(body -> asyncResultHandler.handle(succeededFuture(Response.ok(body, APPLICATION_JSON).build())))
-      .onFailure(t -> handleErrorResponse(asyncResultHandler, helper, t));
+                                     Handler<AsyncResult<Response>> asyncResultHandler, Context context) {
+    var requestContext = new RequestContext(context, okapiHeaders);
+    context.executeBlocking(() -> exchangeService.getExchangeRate(from, to, context, requestContext)
+      .onSuccess(exchangeRate -> asyncResultHandler.handle(succeededFuture(buildOkResponse(exchangeRate))))
+      .onFailure(fail -> handleErrorResponse(asyncResultHandler, fail)));
   }
 
   @Override
-  public void getFinanceCalculateExchange(String from, String to, Number amount, Number rate,
-                                          Map<String, String> okapiHeaders, Handler<AsyncResult<Response>> asyncResultHandler,
-                                          Context vertxContext) {
-    ExchangeHelper helper = new ExchangeHelper(vertxContext);
-    vertxContext.executeBlocking(() -> helper.calculateExchange(from, to, amount, rate))
-      .onSuccess(body -> asyncResultHandler.handle(succeededFuture(Response.ok(body, APPLICATION_JSON).build())))
-      .onFailure(e -> handleErrorResponse(asyncResultHandler, helper, e));
+  public void getFinanceCalculateExchange(String from, String to, Number amount, Number customRate, Map<String, String> okapiHeaders,
+                                          Handler<AsyncResult<Response>> asyncResultHandler, Context context) {
+    var requestContext = new RequestContext(context, okapiHeaders);
+    context.executeBlocking(() -> exchangeService.calculateExchange(from, to, amount, customRate, context, requestContext)
+      .onSuccess(convertedAmount -> asyncResultHandler.handle(succeededFuture(buildOkResponse(convertedAmount))))
+      .onFailure(fail -> handleErrorResponse(asyncResultHandler, fail)));
   }
 }
diff --git a/src/main/java/org/folio/rest/util/ResourcePathResolver.java b/src/main/java/org/folio/rest/util/ResourcePathResolver.java
index cb8f4dca..ae2d0c75 100644
--- a/src/main/java/org/folio/rest/util/ResourcePathResolver.java
+++ b/src/main/java/org/folio/rest/util/ResourcePathResolver.java
@@ -35,6 +35,7 @@ private ResourcePathResolver() {
   public static final String BUDGET_EXPENSE_CLASSES = "budgetExpenseClasses";
   public static final String ACQUISITIONS_UNITS = "acquisitionsUnits";
   public static final String ACQUISITIONS_MEMBERSHIPS = "acquisitionsMemberships";
+  public static final String EXCHANGE_RATE_SOURCE = "exchangeRateSource";
 
   private static final Map<String, String> SUB_OBJECT_ITEM_APIS;
   private static final Map<String, String> SUB_OBJECT_COLLECTION_APIS;
@@ -65,6 +66,7 @@ private ResourcePathResolver() {
     apis.put(BUDGET_EXPENSE_CLASSES, "/finance-storage/budget-expense-classes");
     apis.put(ACQUISITIONS_UNITS, "/acquisitions-units-storage/units");
     apis.put(ACQUISITIONS_MEMBERSHIPS, "/acquisitions-units-storage/memberships");
+    apis.put(EXCHANGE_RATE_SOURCE, "/finance-storage/exchange-rate-source");
 
     SUB_OBJECT_COLLECTION_APIS = Collections.unmodifiableMap(apis);
     SUB_OBJECT_ITEM_APIS = Collections.unmodifiableMap(
diff --git a/src/main/java/org/folio/services/exchange/CustomJsonExchangeRateProvider.java b/src/main/java/org/folio/services/exchange/CustomJsonExchangeRateProvider.java
new file mode 100644
index 00000000..b8763a58
--- /dev/null
+++ b/src/main/java/org/folio/services/exchange/CustomJsonExchangeRateProvider.java
@@ -0,0 +1,64 @@
+package org.folio.services.exchange;
+
+import lombok.extern.log4j.Log4j2;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+import org.folio.services.exchange.handler.ConveraCustomJsonHandler;
+import org.folio.services.exchange.handler.CurrencyApiCustomJsonHandler;
+import org.folio.services.exchange.handler.TreasuryGovCustomJsonHandler;
+import org.javamoney.moneta.convert.ExchangeRateBuilder;
+import org.javamoney.moneta.spi.AbstractRateProvider;
+import org.javamoney.moneta.spi.DefaultNumberValue;
+
+import javax.money.convert.ConversionContext;
+import javax.money.convert.ConversionQuery;
+import javax.money.convert.ExchangeRate;
+import javax.money.convert.ProviderContext;
+import javax.money.convert.ProviderContextBuilder;
+import javax.money.convert.RateType;
+import java.math.BigDecimal;
+import java.net.http.HttpClient;
+
+@Log4j2
+public class CustomJsonExchangeRateProvider extends AbstractRateProvider {
+
+  private static final ProviderContext CONTEXT = ProviderContextBuilder.of("CUSTOM", RateType.REALTIME)
+    .set("providerDescription", "Custom exchange rate provider")
+    .build();
+
+  private final HttpClient httpClient;
+  private final ExchangeRateSource rateSource;
+
+  public CustomJsonExchangeRateProvider(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(CONTEXT);
+    this.httpClient = httpClient;
+    this.rateSource = rateSource;
+  }
+
+  @Override
+  public ExchangeRate getExchangeRate(ConversionQuery query) {
+    var from = query.getBaseCurrency();
+    var to = query.getCurrency();
+
+    var exchangeRate = getExchangeRateFromHandler(from.getCurrencyCode(), to.getCurrencyCode());
+    var builder = new ExchangeRateBuilder(ConversionContext.of());
+    builder.setBase(from);
+    builder.setTerm(to);
+    builder.setFactor(DefaultNumberValue.of(exchangeRate));
+
+    return builder.build();
+  }
+
+  public BigDecimal getExchangeRateFromHandler(String from, String to) {
+    var handler =  switch (rateSource.getProviderType()) {
+      case CURRENCYAPI_COM -> new CurrencyApiCustomJsonHandler(httpClient, rateSource);
+      case TREASURY_GOV -> new TreasuryGovCustomJsonHandler(httpClient, rateSource);
+      case CONVERA_COM -> new ConveraCustomJsonHandler(httpClient, rateSource);
+    };
+
+    var exchangeRate = handler.getExchangeRateFromApi(from, to);
+    log.info("getExchangeRateFromHandler:: Using {} handler with exchange rate {} -> {}: {}",
+      rateSource.getProviderType().name(), from, to, exchangeRate);
+
+    return exchangeRate;
+  }
+}
diff --git a/src/main/java/org/folio/services/exchange/ExchangeService.java b/src/main/java/org/folio/services/exchange/ExchangeService.java
new file mode 100644
index 00000000..1970d4c0
--- /dev/null
+++ b/src/main/java/org/folio/services/exchange/ExchangeService.java
@@ -0,0 +1,82 @@
+package org.folio.services.exchange;
+
+import io.vertx.core.Context;
+import io.vertx.core.Future;
+import org.folio.HttpStatus;
+import org.folio.rest.core.RestClient;
+import org.folio.rest.core.models.RequestContext;
+import org.folio.rest.core.models.RequestEntry;
+import org.folio.rest.exception.HttpException;
+import org.folio.rest.helper.ExchangeHelper;
+import org.folio.rest.jaxrs.model.ExchangeRate;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+import org.javamoney.moneta.Money;
+import org.springframework.stereotype.Service;
+
+import javax.money.convert.ConversionQueryBuilder;
+import java.net.http.HttpClient;
+import java.util.Objects;
+
+import static org.folio.rest.util.ResourcePathResolver.EXCHANGE_RATE_SOURCE;
+import static org.folio.rest.util.ResourcePathResolver.resourcesPath;
+
+@Service
+public class ExchangeService {
+
+  private final RestClient restClient;
+  private final HttpClient httpClient;
+
+  public ExchangeService(RestClient restClient, HttpClient httpClient) {
+    this.restClient = restClient;
+    this.httpClient = httpClient;
+  }
+
+  public Future<ExchangeRate> getExchangeRate(String from, String to, Context context, RequestContext requestContext) {
+    return getExchangeRateSource(requestContext)
+      .flatMap(rateSource -> {
+        if (Objects.isNull(rateSource) || isRateSourceDisabled(rateSource)) {
+          var exchangeRate = new ExchangeHelper(context).getExchangeRate(from, to);
+          return Future.succeededFuture(exchangeRate);
+        }
+        var provider = new CustomJsonExchangeRateProvider(httpClient, rateSource);
+        var exchangeRate = new ExchangeRate().withFrom(from).withTo(to)
+          .withExchangeRate(provider.getExchangeRateFromHandler(from, to).doubleValue());
+        return Future.succeededFuture(exchangeRate);
+      });
+  }
+
+  public Future<Double> calculateExchange(String from, String to, Number amount, Number customRate, Context context,
+                                          RequestContext requestContext) {
+    return getExchangeRateSource(requestContext)
+      .flatMap(rateSource -> {
+        if (Objects.isNull(rateSource) || isRateSourceDisabled(rateSource) || Objects.nonNull(customRate)) {
+          var convertedAmount = new ExchangeHelper(context).calculateExchange(from, to, amount, customRate);
+          return Future.succeededFuture(convertedAmount);
+        }
+        var provider = new CustomJsonExchangeRateProvider(httpClient, rateSource);
+        var query = ConversionQueryBuilder.of()
+          .setBaseCurrency(from).setTermCurrency(to)
+          .build();
+        var convertedAmount = Money.of(amount, from)
+          .with(provider.getCurrencyConversion(query))
+          .getNumber()
+          .doubleValueExact();
+        return Future.succeededFuture(convertedAmount);
+      });
+  }
+
+  public Future<ExchangeRateSource> getExchangeRateSource(RequestContext requestContext) {
+    var requestEntry = new RequestEntry(resourcesPath(EXCHANGE_RATE_SOURCE));
+    return restClient.get(requestEntry.buildEndpoint(), ExchangeRateSource.class, requestContext)
+      .recover(e -> {
+        if (e instanceof HttpException ex && ex.getCode() == HttpStatus.HTTP_NOT_FOUND.toInt()) {
+          return Future.succeededFuture(null);
+        }
+        return Future.failedFuture(e);
+      });
+  }
+
+  private boolean isRateSourceDisabled(ExchangeRateSource rateSource) {
+    return Objects.nonNull(rateSource) && Boolean.FALSE.equals(rateSource.getEnabled());
+  }
+}
diff --git a/src/main/java/org/folio/services/exchange/handler/AbstractCustomJsonHandler.java b/src/main/java/org/folio/services/exchange/handler/AbstractCustomJsonHandler.java
new file mode 100644
index 00000000..ffa92bb0
--- /dev/null
+++ b/src/main/java/org/folio/services/exchange/handler/AbstractCustomJsonHandler.java
@@ -0,0 +1,21 @@
+package org.folio.services.exchange.handler;
+
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import java.math.BigDecimal;
+import java.net.http.HttpClient;
+
+public abstract class AbstractCustomJsonHandler {
+
+  protected static final String APPLICATION_JSON_UTF_8 = "application/json; charset=utf-8";
+
+  protected final HttpClient httpClient;
+  protected final ExchangeRateSource rateSource;
+
+  protected AbstractCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    this.httpClient = httpClient;
+    this.rateSource = rateSource;
+  }
+
+  public abstract BigDecimal getExchangeRateFromApi(String from, String to);
+}
diff --git a/src/main/java/org/folio/services/exchange/handler/ConveraCustomJsonHandler.java b/src/main/java/org/folio/services/exchange/handler/ConveraCustomJsonHandler.java
new file mode 100644
index 00000000..824b6c2d
--- /dev/null
+++ b/src/main/java/org/folio/services/exchange/handler/ConveraCustomJsonHandler.java
@@ -0,0 +1,18 @@
+package org.folio.services.exchange.handler;
+
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import java.math.BigDecimal;
+import java.net.http.HttpClient;
+
+public class ConveraCustomJsonHandler extends AbstractCustomJsonHandler {
+
+  public ConveraCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(httpClient, rateSource);
+  }
+
+  @Override
+  public BigDecimal getExchangeRateFromApi(String from, String to) {
+    throw new UnsupportedOperationException("Convera exchange rate provider is unimplemented");
+  }
+}
diff --git a/src/main/java/org/folio/services/exchange/handler/CurrencyApiCustomJsonHandler.java b/src/main/java/org/folio/services/exchange/handler/CurrencyApiCustomJsonHandler.java
new file mode 100644
index 00000000..1e1dadb5
--- /dev/null
+++ b/src/main/java/org/folio/services/exchange/handler/CurrencyApiCustomJsonHandler.java
@@ -0,0 +1,46 @@
+package org.folio.services.exchange.handler;
+
+import io.vertx.core.json.JsonObject;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import javax.ws.rs.core.HttpHeaders;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+@Log4j2
+public class CurrencyApiCustomJsonHandler extends AbstractCustomJsonHandler {
+
+  private static final String URI_TEMPLATE = "%s?base_currency=%s&currencies=%s";
+  private static final String API_KEY = "apikey";
+  private static final String DATA = "data";
+  private static final String VALUE = "value";
+
+  public CurrencyApiCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(httpClient, rateSource);
+  }
+
+  @Override
+  @SneakyThrows
+  public BigDecimal getExchangeRateFromApi(String from, String to) {
+    var preparedUri = String.format(URI_TEMPLATE, rateSource.getProviderUri(), from, to);
+    var httpRequest = HttpRequest.newBuilder()
+      .uri(new URI(preparedUri))
+      .headers(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF_8, API_KEY, rateSource.getApiKey()).GET()
+      .build();
+
+    var httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+    log.debug("getExchangeRateFromApi:: Status code: {}, body: {}", httpResponse.statusCode(), httpResponse.body());
+
+    var exchangeRate = new JsonObject(httpResponse.body())
+      .getJsonObject(DATA)
+      .getJsonObject(to)
+      .getString(VALUE);
+
+    return new BigDecimal(exchangeRate);
+  }
+}
diff --git a/src/main/java/org/folio/services/exchange/handler/TreasuryGovCustomJsonHandler.java b/src/main/java/org/folio/services/exchange/handler/TreasuryGovCustomJsonHandler.java
new file mode 100644
index 00000000..245b0e81
--- /dev/null
+++ b/src/main/java/org/folio/services/exchange/handler/TreasuryGovCustomJsonHandler.java
@@ -0,0 +1,218 @@
+package org.folio.services.exchange.handler;
+
+import io.vertx.core.json.JsonObject;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.lang3.StringUtils;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import javax.ws.rs.core.HttpHeaders;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+@Log4j2
+public class TreasuryGovCustomJsonHandler extends AbstractCustomJsonHandler {
+
+  private static final String URI_TEMPLATE = "%s?fields=country_currency_desc,exchange_rate,record_date"
+    + "&filter=country_currency_desc:in:(%s),record_date:lte:%s"
+    + "&sort=-record_date"
+    + "&page[size]=1";
+  private static final String DATA = "data";
+  private static final String EXCHANGE_RATE = "exchange_rate";
+
+  public TreasuryGovCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(httpClient, rateSource);
+  }
+
+  @Override
+  @SneakyThrows
+  public BigDecimal getExchangeRateFromApi(String from, String to) {
+    if (!StringUtils.equals(from, CountryCurrency.USD.name())) {
+      throw new IllegalStateException("Current handler supports only USD as a 'from' currency");
+    }
+
+    var requestDate = ZonedDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
+    var normalizedPreparedUri = String.format(URI_TEMPLATE, rateSource.getProviderUri(),
+      CountryCurrency.valueOf(to).value, requestDate).replace(" ", "+");
+    var httpRequest = HttpRequest.newBuilder()
+      .uri(new URI(normalizedPreparedUri))
+      .headers(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF_8).GET()
+      .build();
+
+    var httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+    log.debug("getExchangeRateFromApi:: Status code: {}, body: {}", httpResponse.statusCode(), httpResponse.body());
+
+    var exchangeRate = new JsonObject(httpResponse.body())
+      .getJsonArray(DATA)
+      .getJsonObject(0)
+      .getString(EXCHANGE_RATE);
+
+    return new BigDecimal(exchangeRate);
+  }
+
+  enum CountryCurrency {
+    USD("United States of America-Dollar"),
+    ZWL("Zimbabwe-Rtgs"),
+    AFN("Afghanistan-Afghani"),
+    ALL("Albania-Lek"),
+    DZD("Algeria-Dinar"),
+    AOA("Angola-Kwanza"),
+    XCD("Antigua & Barbuda-East Caribbean Dollar"),
+    ARS("Argentina-Peso"),
+    AMD("Armenia-Dram"),
+    AUD("Australia-Dollar"),
+    EUR("Euro Zone-Euro"),
+    AZN("Azerbaijan-Manat"),
+    BSD("Bahamas-Dollar"),
+    BHD("Bahrain-Dinar"),
+    BDT("Bangladesh-Taka"),
+    BBD("Barbados-Dollar"),
+    BYN("Belarus-New Ruble"),
+    BZD("Belize-Dollar"),
+    BMD("Bermuda-Dollar"),
+    BOB("Bolivia-Boliviano"),
+    BAM("Bosnia-Marka"),
+    BWP("Botswana-Pula"),
+    BRL("Brazil-Real"),
+    BND("Brunei-Dollar"),
+    BGN("Bulgaria-Lev New"),
+    XOF("Guinea Bissau-Cfa Franc"),
+    BIF("Burundi-Franc"),
+    KHR("Cambodia-Riel"),
+    CAD("Canada-Dollar"),
+    CVE("Cape Verde-Escudo"),
+    KYD("Cayman Islands-Dollar"),
+    XAF("Central African Republic-Cfa Franc"),
+    CLP("Chile-Peso"),
+    CNY("China-Renminbi"),
+    COP("Colombia-Peso"),
+    KMF("Comoros-Franc"),
+    CRC("Costa Rica-Colon"),
+    HRK("Croatia-Kuna"),
+    CUC("Cuba-Chavito"),
+    CUP("Cuba-Peso"),
+    CZK("Czech Republic-Koruna"),
+    CDF("Democratic Republic Of Congo-Congolese Franc"),
+    DKK("Denmark-Krone"),
+    DJF("Djibouti-Franc"),
+    DOP("Dominican Republic-Peso"),
+    EGP("Egypt-Pound"),
+    SVC("El Salvador-Dollar"),
+    XFA("Equatorial Guinea-Cfa Franc"),
+    ERN("Eritrea-Nakfa"),
+    SZL("Eswatini-Lilangeni"),
+    ETB("Ethiopia-Birr"),
+    FJD("Fiji-Dollar"),
+    GMD("Gambia-Dalasi"),
+    GEL("Georgia-Lari"),
+    GHS("Ghana-Cedi"),
+    GTQ("Guatemala-Quetzal"),
+    GNF("Guinea-Franc"),
+    GYD("Guyana-Dollar"),
+    HTG("Haiti-Gourde"),
+    HNL("Honduras-Lempira"),
+    HKD("Hong Kong-Dollar"),
+    HUF("Hungary-Forint"),
+    ISK("Iceland-Krona"),
+    INR("India-Rupee"),
+    IDR("Indonesia-Rupiah"),
+    IRR("Iran-Rial"),
+    IQD("Iraq-Dinar"),
+    ILS("Israel-Shekel"),
+    JMD("Jamaica-Dollar"),
+    JPY("Japan-Yen"),
+    JOD("Jordan-Dinar"),
+    KZT("Kazakhstan-Tenge"),
+    KES("Kenya-Shilling"),
+    KRW("Korea-Won"),
+    KWD("Kuwait-Dinar"),
+    KGS("Kyrgyzstan-Som"),
+    LAK("Laos-Kip"),
+    LBP("Lebanon-Pound"),
+    LSL("Lesotho-Maloti"),
+    LRD("Liberia-Dollar"),
+    LYD("Libya-Dinar"),
+    MGA("Madagascar-Ariary"),
+    MWK("Malawi-Kwacha"),
+    MYR("Malaysia-Ringgit"),
+    MVR("Maldives-Rufiyaa"),
+    MRU("Mauritania-Ouguiya"),
+    MUR("Mauritius-Rupee"),
+    MXN("Mexico-Peso"),
+    MDL("Moldova-Leu"),
+    MNT("Mongolia-Tugrik"),
+    MAD("Morocco-Dirham"),
+    MZN("Mozambique-Metical"),
+    MMK("Myanmar-Kyat"),
+    NAD("Namibia-Dollar"),
+    NPR("Nepal-Rupee"),
+    ANG("Netherlands Antilles-Guilder"),
+    NZD("New Zealand-Dollar"),
+    NIO("Nicaragua-Cordoba"),
+    NGN("Nigeria-Naira"),
+    NOK("Norway-Krone"),
+    OMR("Oman-Rial"),
+    PKR("Pakistan-Rupee"),
+    PAB("Panama-Balboa"),
+    PGK("Papua New Guinea-Kina"),
+    PYG("Paraguay-Guarani"),
+    PEN("Peru-Sol"),
+    PHP("Philippines-Peso"),
+    PLN("Poland-Zloty"),
+    QAR("Qatar-Riyal"),
+    MKD("Republic Of North Macedonia-Denar"),
+    RON("Romania-New Leu"),
+    RUR("Russia-Ruble"),
+    RWF("Rwanda-Franc"),
+    STN("Sao Tome & Principe-New Dobras"),
+    SAR("Saudi Arabia-Riyal"),
+    RSD("Serbia-Dinar"),
+    SCR("Seychelles-Rupee"),
+    SLL("Sierra Leone-Leone"),
+    SGD("Singapore-Dollar"),
+    SBD("Solomon Islands-Dollar"),
+    SOS("Somali-Shilling"),
+    ZAR("South Africa-Rand"),
+    SSP("South Sudan-Sudanese Pound"),
+    LKR("Sri Lanka-Rupee"),
+    SDG("Sudan-Pound"),
+    SRD("Suriname-Dollar"),
+    SEK("Sweden-Krona"),
+    CHF("Switzerland-Franc"),
+    SYP("Syria-Pound"),
+    TWD("Taiwan-Dollar"),
+    TJS("Tajikistan-Somoni"),
+    TZS("Tanzania-Shilling"),
+    THB("Thailand-Baht"),
+    TOP("Tonga-Pa'Anga"),
+    TTD("Trinidad & Tobago-Dollar"),
+    TND("Tunisia-Dinar"),
+    TRY("Turkey-New Lira"),
+    TMT("Turkmenistan-New Manat"),
+    UGX("Uganda-Shilling"),
+    UAH("Ukraine-Hryvnia"),
+    AED("United Arab Emirates-Dirham"),
+    GBP("United Kingdom-Pound"),
+    UYU("Uruguay-Peso"),
+    UZS("Uzbekistan-Som"),
+    VUV("Vanuatu-Vatu"),
+    VES("Venezuela-Bolivar Soberano"),
+    VEF("Venezuela-Fuerte"),
+    VND("Vietnam-Dong"),
+    WST("Western Samoa-Tala"),
+    YER("Yemen-Rial"),
+    ZMW("Zambia-New Kwacha"),
+    ZWG("Zimbabwe-Gold");
+
+    private final String value;
+
+    CountryCurrency(String value) {
+      this.value = value;
+    }
+  }
+}
diff --git a/src/main/resources/javamoney.properties b/src/main/resources/javamoney.properties
index dd618252..464ab32b 100644
--- a/src/main/resources/javamoney.properties
+++ b/src/main/resources/javamoney.properties
@@ -1,15 +1,29 @@
-# ECB Rates
+# Standard provider chain
+# the builtin providers that are to be enabled and used by the app
+{1}conversion.default-chain=IDENT,ECB
+
+# Decimal rounding per provider
+# default decimal point precision enabled for ECB-based provider, in our case for only for ECBCurrentRateProvider
+{1}ecb.digit.fraction=6
+
+# Settings used to enabled ECBCurrentRateProvider
+# if the type is set to scheduled then this provider is enabled in the lookup by the ServiceLoader
 {1}load.ECBCurrentRateProvider.type=SCHEDULED
 {1}load.ECBCurrentRateProvider.period=03:00:00
-{1}load.ECBCurrentRateProvider.resource=/java-money/defaults/ECB/eurofxref-daily.xml
+{1}load.ECBCurrentRateProvider.resource=org/javamoney/moneta/convert/ecb/defaults/eurofxref-daily.xml
 {1}load.ECBCurrentRateProvider.urls=https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml
 {1}load.ECBCurrentRateProvider.startRemote=false
 
-{1}load.ECBHistoric90RateProvider.type=LAZY
-{1}load.ECBHistoricRateProvider.type=LAZY
-{1}load.ECBHistoric90RateProvider.urls=
-{1}load.ECBHistoricRateProvider.urls=
+# Disable ECBHistoric90RateProvider
+# this provider is to be excluded by the ServiceLoader lookup to avoid related exceptions on app startup
+{1}load.ECBHistoric90RateProvider.type=NEVER
+{1}load.ECBHistoric90RateProvider.startRemote=false
+{1}load.ECBHistoric90RateProvider.resource=org/javamoney/moneta/convert/ecb/defaults/eurofxref-hist-90d.xml
+{1}load.ECBHistoric90RateProvider.urls=https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml
 
-{1}conversion.default-chain=IDENT,ECB
-
-{1}ecb.digit.fraction=6
+# Disable ECBHistoricRateProvider
+# this provider is to be excluded by the ServiceLoader lookup to avoid related exceptions on app startup
+{1}load.ECBHistoricRateProvider.type=NEVER
+{1}load.ECBHistoricRateProvider.startRemote=false
+{1}load.ECBHistoricRateProvider.resource=org/javamoney/moneta/convert/ecb/defaults/eurofxref-hist.xml
+{1}load.ECBHistoricRateProvider.urls=https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml
diff --git a/src/test/java/org/folio/ApiTestSuite.java b/src/test/java/org/folio/ApiTestSuite.java
index 03610e3a..d0476693 100644
--- a/src/test/java/org/folio/ApiTestSuite.java
+++ b/src/test/java/org/folio/ApiTestSuite.java
@@ -34,6 +34,7 @@
 import org.folio.services.budget.BudgetServiceTest;
 import org.folio.services.budget.CreateBudgetServiceTest;
 import org.folio.services.budget.RecalculateBudgetServiceTest;
+import org.folio.services.exchange.ExchangeServiceTest;
 import org.folio.services.financedata.FinanceDataServiceTest;
 import org.folio.services.financedata.FinanceDataValidatorTest;
 import org.folio.services.fiscalyear.FiscalYearApiServiceTest;
@@ -267,4 +268,7 @@ class FundUpdateLogApiTestNested extends FundUpdateLogApiTest {}
 
   @Nested
   class FundUpdateLogServiceTestNested extends FundUpdateLogServiceTest {}
+
+  @Nested
+  class ExchangeServiceTestNested extends ExchangeServiceTest {}
 }
diff --git a/src/test/java/org/folio/services/exchange/ExchangeServiceTest.java b/src/test/java/org/folio/services/exchange/ExchangeServiceTest.java
new file mode 100644
index 00000000..ac0fffc4
--- /dev/null
+++ b/src/test/java/org/folio/services/exchange/ExchangeServiceTest.java
@@ -0,0 +1,141 @@
+package org.folio.services.exchange;
+
+import io.vertx.core.Context;
+import io.vertx.core.Future;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
+import org.folio.HttpStatus;
+import org.folio.rest.core.RestClient;
+import org.folio.rest.core.models.RequestContext;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+import org.folio.util.CopilotGenerated;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.IOException;
+import java.net.http.HttpClient;
+import java.net.http.HttpResponse;
+import java.util.UUID;
+
+import static org.folio.rest.jaxrs.model.ExchangeRateSource.ProviderType.CURRENCYAPI_COM;
+import static org.folio.rest.jaxrs.model.ExchangeRateSource.ProviderType.TREASURY_GOV;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@SuppressWarnings("unchecked")
+@ExtendWith(VertxExtension.class)
+@CopilotGenerated(partiallyGenerated = true)
+public class ExchangeServiceTest {
+
+  @Mock private RestClient restClient;
+  @Mock private HttpClient httpClient;
+  @Mock private HttpResponse<String> httpResponse;
+  @Mock private Context context;
+  @Mock private RequestContext requestContext;
+  @InjectMocks private ExchangeService exchangeService;
+
+  private AutoCloseable openMocks;
+
+  @BeforeEach
+  void setUp() {
+    openMocks = MockitoAnnotations.openMocks(this);
+  }
+
+  @AfterEach
+  void tearDown() throws Exception {
+    if (openMocks != null) {
+      openMocks.close();
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({"TREASURY_GOV,0.961d", "CURRENCYAPI_COM,0.9052401139"})
+  void testGetExchangeRateUsingCustomJsonExchangeRateProvider(ExchangeRateSource.ProviderType providerType, double expectedAmount,
+                                                              VertxTestContext testContext) throws IOException, InterruptedException {
+    when(httpResponse.statusCode()).thenReturn(HttpStatus.HTTP_OK.toInt());
+    when(httpResponse.body()).thenReturn(createResponseBody(providerType));
+    when(restClient.get(any(), any(), any())).thenReturn(Future.succeededFuture(createExchangeRateSource(providerType)));
+    when(httpClient.send(any(), any(HttpResponse.BodyHandlers.ofString().getClass()))).thenReturn(httpResponse);
+
+    exchangeService.getExchangeRate("USD", "EUR", context, requestContext)
+      .onComplete(testContext.succeeding(result -> testContext.verify(() -> {
+        assertEquals("USD", result.getFrom());
+        assertEquals("EUR", result.getTo());
+        assertEquals(expectedAmount, result.getExchangeRate());
+        testContext.completeNow();
+      })));
+  }
+
+  @Test
+  void testGetExchangeRateUsingCustomJsonExchangeRateProviderWithInvalidTreasureGovFromCurrency(VertxTestContext testContext)
+    throws IOException, InterruptedException {
+    when(httpResponse.statusCode()).thenReturn(HttpStatus.HTTP_OK.toInt());
+    when(httpResponse.body()).thenReturn(createResponseBody(TREASURY_GOV));
+    when(restClient.get(any(), any(), any())).thenReturn(Future.succeededFuture(createExchangeRateSource(TREASURY_GOV)));
+    when(httpClient.send(any(), any(HttpResponse.BodyHandlers.ofString().getClass()))).thenReturn(httpResponse);
+
+    exchangeService.getExchangeRate("EUR", "USD", context, requestContext)
+      .onComplete(testContext.failing(throwable -> testContext.verify(() -> {
+        assertEquals("Current handler supports only USD as a 'from' currency", throwable.getMessage());
+        testContext.completeNow();
+      })));
+  }
+
+  @ParameterizedTest
+  @CsvSource({"TREASURY_GOV,9.61d", "CURRENCYAPI_COM,9.052401139"})
+  void testCalculateExchangeRateUsingCustomJsonExchangeRateProvider(ExchangeRateSource.ProviderType providerType, double expectedAmount,
+                                                                    VertxTestContext testContext) throws IOException, InterruptedException {
+    when(httpResponse.statusCode()).thenReturn(HttpStatus.HTTP_OK.toInt());
+    when(httpResponse.body()).thenReturn(createResponseBody(providerType));
+    when(restClient.get(any(), any(), any())).thenReturn(Future.succeededFuture(createExchangeRateSource(providerType)));
+    when(httpClient.send(any(), any(HttpResponse.BodyHandlers.ofString().getClass()))).thenReturn(httpResponse);
+
+    exchangeService.calculateExchange("USD", "EUR", 10, null, context, requestContext)
+      .onComplete(testContext.succeeding(result -> testContext.verify(() -> {
+        assertEquals(expectedAmount, result);
+        testContext.completeNow();
+      })));
+  }
+
+  private ExchangeRateSource createExchangeRateSource(ExchangeRateSource.ProviderType providerType) {
+    return switch (providerType) {
+      case TREASURY_GOV -> new ExchangeRateSource()
+        .withId(UUID.randomUUID().toString()).withEnabled(true)
+        .withProviderType(TREASURY_GOV)
+        .withProviderUri("https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v1/accounting/od/rates_of_exchange");
+      case CURRENCYAPI_COM -> new ExchangeRateSource()
+        .withId(UUID.randomUUID().toString()).withEnabled(true)
+        .withProviderType(CURRENCYAPI_COM)
+        .withProviderUri("https://api.currencyapi.com/v3/latest")
+        .withApiKey("apiKey");
+      case CONVERA_COM -> null;
+    };
+  }
+
+  private String createResponseBody(ExchangeRateSource.ProviderType providerType) {
+    return switch (providerType) {
+      case TREASURY_GOV -> new JsonObject().put("data", new JsonArray()
+        .add(new JsonObject()
+          .put("country_currency_desc", "Euro Zone-Euro")
+          .put("exchange_rate", "0.961")
+          .put("record_date", "2024-12-31")))
+        .toString();
+      case CURRENCYAPI_COM -> new JsonObject().put("data", new JsonObject()
+        .put("EUR", new JsonObject()
+          .put("code", "EUR")
+          .put("value", "0.9052401139")))
+        .toString();
+      case CONVERA_COM -> null;
+    };
+  }
+}


Reviews:
user: login: SerhiiNosko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:05:52Z
user: login: SerhiiNosko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:07:44Z
user: login: SerhiiNosko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:17:59Z
user: login: BKadirkhodjaev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:27:04Z
user: login: Saba-Zedginidze-EPAM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:04:39Z
user: login: BKadirkhodjaev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:07:14Z
user: login: SerhiiNosko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:08:57Z
user: login: SerhiiNosko
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:09:16Z
user: login: Saba-Zedginidze-EPAM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:09:57Z
user: login: Saba-Zedginidze-EPAM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:17:46Z
user: login: Saba-Zedginidze-EPAM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:18:22Z

Review comments:
diff_hunk: @@ -0,0 +1,21 @@
+package org.folio.services.exchange.handler;
+
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import java.math.BigDecimal;
+import java.net.http.HttpClient;
+
+public abstract class AbstractCustomJsonHandler {
+
+  protected static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
path: src/main/java/org/folio/services/exchange/handler/AbstractCustomJsonHandler.java
user: login: SerhiiNosko
body: better to declare new bean in ServicesConfiguration and inject it here
created_at: 2025-04-03T10:05:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+package org.folio.services.exchange.handler;
+
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import java.math.BigDecimal;
+import java.net.http.HttpClient;
+
+public abstract class AbstractCustomJsonHandler {
+
+  protected static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
+  protected static final String CONTENT_TYPE = "Content-Type";
path: src/main/java/org/folio/services/exchange/handler/AbstractCustomJsonHandler.java
user: login: SerhiiNosko
body: can use constant from javax.ws.rs.core.HttpHeaders instead of decalring new one
created_at: 2025-04-03T10:07:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+package org.folio.services.exchange.handler;
+
+import io.vertx.core.json.JsonObject;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.lang3.StringUtils;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import javax.ws.rs.core.HttpHeaders;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.IsoFields;
+import java.time.temporal.TemporalAdjusters;
+
+@Log4j2
+public class TreasuryGovCustomJsonHandler extends AbstractCustomJsonHandler {
+
+  private static final String DATA = "data";
+  private static final String EXCHANGE_RATE = "exchange_rate";
+
+  public TreasuryGovCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(httpClient, rateSource);
+  }
+
+  @Override
+  @SneakyThrows
+  public BigDecimal getExchangeRateFromApi(String from, String to) {
+    if (!StringUtils.equals(from, "USD")) {
+      throw new IllegalStateException("Treasury custom exchange rate API only supports USD as a 'from' currency");
+    }
+
+    var currentQuarterLastDate = getFiscalQuarterLastDate();
+    var preparedUri = String.format("%s?fields=country_currency_desc,exchange_rate,record_date"
+        + "&filter=country_currency_desc:in:(%s),record_date:gte:%s"
+        + "&sort=-record_date"
+        + "&page[size]=1",
+      super.rateSource.getProviderUri(), CountryCurrency.valueOf(to).value, currentQuarterLastDate).replace(" " , "+");
+    var httpRequest = HttpRequest.newBuilder()
+      .uri(new URI(preparedUri))
+      .headers(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF_8).GET()
+      .build();
+
+    var httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+    log.debug("getExchangeRateFromApi:: Status code: {}, body: {}", httpResponse.statusCode(), httpResponse.body());
+
+    var exchangeRate = new JsonObject(httpResponse.body())
+      .getJsonArray(DATA)
+      .getJsonObject(0)
+      .getString(EXCHANGE_RATE);
+
+    return new BigDecimal(exchangeRate);
+  }
+
+  enum CountryCurrency {
+    EUR("Euro Zone-Euro"),
+    CAD("Canada-Dollar"),
+    ILS("Israel-Shekel");
path: src/main/java/org/folio/services/exchange/handler/TreasuryGovCustomJsonHandler.java
user: login: SerhiiNosko
body: To fill full list we should get all available values from some source?
created_at: 2025-04-03T13:17:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+package org.folio.services.exchange.handler;
+
+import io.vertx.core.json.JsonObject;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.lang3.StringUtils;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import javax.ws.rs.core.HttpHeaders;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.IsoFields;
+import java.time.temporal.TemporalAdjusters;
+
+@Log4j2
+public class TreasuryGovCustomJsonHandler extends AbstractCustomJsonHandler {
+
+  private static final String DATA = "data";
+  private static final String EXCHANGE_RATE = "exchange_rate";
+
+  public TreasuryGovCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(httpClient, rateSource);
+  }
+
+  @Override
+  @SneakyThrows
+  public BigDecimal getExchangeRateFromApi(String from, String to) {
+    if (!StringUtils.equals(from, "USD")) {
+      throw new IllegalStateException("Treasury custom exchange rate API only supports USD as a 'from' currency");
+    }
+
+    var currentQuarterLastDate = getFiscalQuarterLastDate();
+    var preparedUri = String.format("%s?fields=country_currency_desc,exchange_rate,record_date"
+        + "&filter=country_currency_desc:in:(%s),record_date:gte:%s"
+        + "&sort=-record_date"
+        + "&page[size]=1",
+      super.rateSource.getProviderUri(), CountryCurrency.valueOf(to).value, currentQuarterLastDate).replace(" " , "+");
+    var httpRequest = HttpRequest.newBuilder()
+      .uri(new URI(preparedUri))
+      .headers(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF_8).GET()
+      .build();
+
+    var httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
+    log.debug("getExchangeRateFromApi:: Status code: {}, body: {}", httpResponse.statusCode(), httpResponse.body());
+
+    var exchangeRate = new JsonObject(httpResponse.body())
+      .getJsonArray(DATA)
+      .getJsonObject(0)
+      .getString(EXCHANGE_RATE);
+
+    return new BigDecimal(exchangeRate);
+  }
+
+  enum CountryCurrency {
+    EUR("Euro Zone-Euro"),
+    CAD("Canada-Dollar"),
+    ILS("Israel-Shekel");
path: src/main/java/org/folio/services/exchange/handler/TreasuryGovCustomJsonHandler.java
user: login: BKadirkhodjaev
body: > To fill full list we should get all available values from some source?

Yeah, I am preparing additional 195 currencies at the moment

UPDATE: The actual number is less because some use the same currencies, for example USD, EUR, XAF, XOF are used by multiple countries
created_at: 2025-04-03T13:27:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+package org.folio.services.exchange;
+
+import io.vertx.core.Context;
+import io.vertx.core.Future;
+import org.folio.HttpStatus;
+import org.folio.rest.core.RestClient;
+import org.folio.rest.core.models.RequestContext;
+import org.folio.rest.core.models.RequestEntry;
+import org.folio.rest.exception.HttpException;
+import org.folio.rest.helper.ExchangeHelper;
+import org.folio.rest.jaxrs.model.ExchangeRate;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+import org.javamoney.moneta.Money;
+import org.springframework.stereotype.Service;
+
+import javax.money.convert.ConversionQueryBuilder;
+import java.net.http.HttpClient;
+import java.util.Objects;
+
+import static org.folio.rest.util.ResourcePathResolver.EXCHANGE_RATE_SOURCE;
+import static org.folio.rest.util.ResourcePathResolver.resourcesPath;
+
+@Service
+public class ExchangeService {
+
+  private final RestClient restClient;
+  private final HttpClient httpClient;
+
+  public ExchangeService(RestClient restClient, HttpClient httpClient) {
+    this.restClient = restClient;
+    this.httpClient = httpClient;
+  }
+
+  public Future<ExchangeRate> getExchangeRate(String from, String to, Context context, RequestContext requestContext) {
+    return getExchangeRateSource(requestContext)
+      .flatMap(rateSource -> {
+        if (Objects.isNull(rateSource) || Boolean.FALSE.equals(rateSource.getEnabled())) {
path: src/main/java/org/folio/services/exchange/ExchangeService.java
user: login: Saba-Zedginidze-EPAM
body: Probably would be a better idea to compare to TRUE in case of null value to be treated as false
created_at: 2025-04-04T13:04:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+package org.folio.services.exchange;
+
+import io.vertx.core.Context;
+import io.vertx.core.Future;
+import org.folio.HttpStatus;
+import org.folio.rest.core.RestClient;
+import org.folio.rest.core.models.RequestContext;
+import org.folio.rest.core.models.RequestEntry;
+import org.folio.rest.exception.HttpException;
+import org.folio.rest.helper.ExchangeHelper;
+import org.folio.rest.jaxrs.model.ExchangeRate;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+import org.javamoney.moneta.Money;
+import org.springframework.stereotype.Service;
+
+import javax.money.convert.ConversionQueryBuilder;
+import java.net.http.HttpClient;
+import java.util.Objects;
+
+import static org.folio.rest.util.ResourcePathResolver.EXCHANGE_RATE_SOURCE;
+import static org.folio.rest.util.ResourcePathResolver.resourcesPath;
+
+@Service
+public class ExchangeService {
+
+  private final RestClient restClient;
+  private final HttpClient httpClient;
+
+  public ExchangeService(RestClient restClient, HttpClient httpClient) {
+    this.restClient = restClient;
+    this.httpClient = httpClient;
+  }
+
+  public Future<ExchangeRate> getExchangeRate(String from, String to, Context context, RequestContext requestContext) {
+    return getExchangeRateSource(requestContext)
+      .flatMap(rateSource -> {
+        if (Objects.isNull(rateSource) || Boolean.FALSE.equals(rateSource.getEnabled())) {
path: src/main/java/org/folio/services/exchange/ExchangeService.java
user: login: BKadirkhodjaev
body: It is short-circuited, do you think `Boolean.FALSE.equals(rateSource.getEnabled())` will throw an NPE?
created_at: 2025-04-04T13:07:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,15 +1,23 @@
-# ECB Rates
+# Standard provider chain
+{1}conversion.default-chain=IDENT,ECB
+
+# Decimal rounding per provider
+{1}ecb.digit.fraction=6
+
 {1}load.ECBCurrentRateProvider.type=SCHEDULED
 {1}load.ECBCurrentRateProvider.period=03:00:00
-{1}load.ECBCurrentRateProvider.resource=/java-money/defaults/ECB/eurofxref-daily.xml
+{1}load.ECBCurrentRateProvider.resource=org/javamoney/moneta/convert/ecb/defaults/eurofxref-daily.xml
 {1}load.ECBCurrentRateProvider.urls=https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml
 {1}load.ECBCurrentRateProvider.startRemote=false
 
-{1}load.ECBHistoric90RateProvider.type=LAZY
-{1}load.ECBHistoricRateProvider.type=LAZY
-{1}load.ECBHistoric90RateProvider.urls=
-{1}load.ECBHistoricRateProvider.urls=
+# Disable ECBHistoric90RateProvider
path: src/main/resources/javamoney.properties
user: login: SerhiiNosko
body: Please add more comments with documenting changes in this file, because it is not very trivial to understand how chain of providers is configured. 
created_at: 2025-04-04T13:08:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,45 @@
+package org.folio.services.exchange.handler;
+
+import io.vertx.core.json.JsonObject;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+
+import javax.ws.rs.core.HttpHeaders;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+@Log4j2
+public class CurrencyApiCustomJsonHandler extends AbstractCustomJsonHandler {
+
+  private static final String API_KEY = "apikey";
+  private static final String DATA = "data";
+  private static final String VALUE = "value";
+
+  public CurrencyApiCustomJsonHandler(HttpClient httpClient, ExchangeRateSource rateSource) {
+    super(httpClient, rateSource);
+  }
+
+  @Override
+  @SneakyThrows
+  public BigDecimal getExchangeRateFromApi(String from, String to) {
+    var preparedUri = String.format("%s?base_currency=%s&currencies=%s", rateSource.getProviderUri(), from, to);
path: src/main/java/org/folio/services/exchange/handler/CurrencyApiCustomJsonHandler.java
user: login: Saba-Zedginidze-EPAM
body: Can be made a static var
created_at: 2025-04-04T13:09:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+package org.folio.services.exchange;
+
+import io.vertx.core.Context;
+import io.vertx.core.Future;
+import org.folio.HttpStatus;
+import org.folio.rest.core.RestClient;
+import org.folio.rest.core.models.RequestContext;
+import org.folio.rest.core.models.RequestEntry;
+import org.folio.rest.exception.HttpException;
+import org.folio.rest.helper.ExchangeHelper;
+import org.folio.rest.jaxrs.model.ExchangeRate;
+import org.folio.rest.jaxrs.model.ExchangeRateSource;
+import org.javamoney.moneta.Money;
+import org.springframework.stereotype.Service;
+
+import javax.money.convert.ConversionQueryBuilder;
+import java.net.http.HttpClient;
+import java.util.Objects;
+
+import static org.folio.rest.util.ResourcePathResolver.EXCHANGE_RATE_SOURCE;
+import static org.folio.rest.util.ResourcePathResolver.resourcesPath;
+
+@Service
+public class ExchangeService {
+
+  private final RestClient restClient;
+  private final HttpClient httpClient;
+
+  public ExchangeService(RestClient restClient, HttpClient httpClient) {
+    this.restClient = restClient;
+    this.httpClient = httpClient;
+  }
+
+  public Future<ExchangeRate> getExchangeRate(String from, String to, Context context, RequestContext requestContext) {
+    return getExchangeRateSource(requestContext)
+      .flatMap(rateSource -> {
+        if (Objects.isNull(rateSource) || Boolean.FALSE.equals(rateSource.getEnabled())) {
path: src/main/java/org/folio/services/exchange/ExchangeService.java
user: login: Saba-Zedginidze-EPAM
body: Now that I checked, `enabled` is a required parameter, so it will not be null, but would still be nice to have nullsafe checks to avoid issues in the future 
created_at: 2025-04-04T13:17:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-04T13:45:22Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=org.folio%3Amod-finance&pullRequest=288) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=org.folio%3Amod-finance&pullRequest=288&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=org.folio%3Amod-finance&pullRequest=288&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=org.folio%3Amod-finance&pullRequest=288&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [95.6% Coverage on New Code](https://sonarcloud.io/component_measures?id=org.folio%3Amod-finance&pullRequest=288&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=org.folio%3Amod-finance&pullRequest=288&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=org.folio%3Amod-finance&pullRequest=288)


author_association: NONE