From 1c711a077ede65da110d01ff8445db973dca0c38 Mon Sep 17 00:00:00 2001
From: Marcus Fihlon
Date: Mon, 20 Apr 2026 10:31:00 +0200
Subject: [PATCH] feat(workshop): add reference implementation for DKP currency
conversion
Provide reference implementation for custom DukePoints (DKP) currency
including local ExchangeRateProvider and bidirectional conversion
between CHF and DKP.
Signed-off-by: Marcus Fihlon
---
.../money/part4/CsvExchangeRateProvider.java | 90 ++++++++++++++-
.../CsvHistoricalExchangeRateProvider.java | 104 +++++++++++++++++-
.../part4/DukePointsExchangeRateProvider.java | 55 ++++++++-
3 files changed, 234 insertions(+), 15 deletions(-)
diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java
index 005f3e4..d508689 100644
--- a/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java
+++ b/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java
@@ -1,18 +1,30 @@
package swiss.fihlon.workshop.money.part4;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
import javax.money.convert.ConversionQuery;
+import javax.money.convert.ConversionContext;
+import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.CurrencyConversion;
import javax.money.convert.ExchangeRate;
import javax.money.convert.ExchangeRateProvider;
import javax.money.convert.ProviderContext;
+import javax.money.convert.ProviderContextBuilder;
+import javax.money.convert.RateType;
+import org.javamoney.moneta.convert.ExchangeRateBuilder;
+import org.javamoney.moneta.spi.DefaultNumberValue;
+import org.javamoney.moneta.spi.LazyBoundCurrencyConversion;
/**
* Bonus exercise 1 for implementing a local CSV-based exchange rate provider with JSR-354.
*
* The provider is intended for workshop usage and should read rates from the classpath resource
* {@code exchange-rates.csv}.
- *
- * The implementation is intentionally incomplete and should be finished by making tests pass.
*/
public class CsvExchangeRateProvider implements ExchangeRateProvider {
@@ -20,6 +32,10 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
* Name of the classpath CSV resource containing exchange rates.
*/
public static final String RESOURCE_NAME = "exchange-rates.csv";
+ private static final ProviderContext CONTEXT =
+ ProviderContextBuilder.of("csv-exchange-rate-provider", RateType.HISTORIC).build();
+
+ private final Map ratesByPair;
/**
* Create a new provider instance.
@@ -27,6 +43,7 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
* The provider should load exchange rates from {@link #RESOURCE_NAME}.
*/
public CsvExchangeRateProvider() {
+ this.ratesByPair = loadRates();
}
/**
@@ -36,7 +53,7 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public ProviderContext getContext() {
- throw new UnsupportedOperationException("TODO");
+ return CONTEXT;
}
/**
@@ -47,7 +64,16 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
- throw new UnsupportedOperationException("TODO");
+ CurrencyPair currencyPair = CurrencyPair.from(conversionQuery);
+ BigDecimal factor = ratesByPair.get(currencyPair);
+ if (factor == null) {
+ return null;
+ }
+ return new ExchangeRateBuilder(getContext().getProviderName(), RateType.DEFERRED)
+ .setBase(conversionQuery.getBaseCurrency())
+ .setTerm(conversionQuery.getCurrency())
+ .setFactor(DefaultNumberValue.of(factor))
+ .build();
}
/**
@@ -60,6 +86,60 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) {
- throw new UnsupportedOperationException("TODO");
+ if (getExchangeRate(conversionQuery) == null) {
+ return null;
+ }
+ ConversionContext conversionContext = ConversionContextBuilder.create(getContext(), RateType.DEFERRED).build();
+ return new LazyBoundCurrencyConversion(conversionQuery, this, conversionContext);
+ }
+
+ /**
+ * Load exchange rates from the classpath CSV resource into a lookup map.
+ *
+ * The CSV is expected to contain a header and records in the format
+ * {@code base,term,rate}.
+ *
+ * @return map keyed by currency pair containing the configured exchange factor
+ */
+ private static Map loadRates() {
+ InputStream inputStream = CsvExchangeRateProvider.class.getClassLoader().getResourceAsStream(RESOURCE_NAME);
+ if (inputStream == null) {
+ throw new IllegalStateException("Could not load classpath resource: " + RESOURCE_NAME);
+ }
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
+ Map rates = new HashMap<>();
+ reader.readLine();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ String[] values = line.split(",");
+ CurrencyPair currencyPair = new CurrencyPair(values[0], values[1]);
+ BigDecimal factor = new BigDecimal(values[2]);
+ rates.put(currencyPair, factor);
+ }
+ return rates;
+ } catch (Exception exception) {
+ throw new IllegalStateException("Failed to parse exchange rates from: " + RESOURCE_NAME, exception);
+ }
+ }
+
+ /**
+ * Simple key for identifying an exchange rate by base and term currency code.
+ *
+ * @param baseCurrencyCode ISO currency code of the base currency
+ * @param termCurrencyCode ISO currency code of the term currency
+ */
+ private record CurrencyPair(String baseCurrencyCode, String termCurrencyCode) {
+ /**
+ * Create a currency-pair key from a conversion query.
+ *
+ * @param conversionQuery query containing base and term currency
+ * @return key for map-based rate lookup
+ */
+ private static CurrencyPair from(ConversionQuery conversionQuery) {
+ return new CurrencyPair(
+ conversionQuery.getBaseCurrency().getCurrencyCode(),
+ conversionQuery.getCurrency().getCurrencyCode());
+ }
}
}
diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java
index 320bbaa..802bd1f 100644
--- a/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java
+++ b/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java
@@ -1,11 +1,27 @@
package swiss.fihlon.workshop.money.part4;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.math.BigDecimal;
import java.time.LocalDate;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.TreeMap;
import javax.money.convert.ConversionQuery;
+import javax.money.convert.ConversionContext;
+import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.CurrencyConversion;
import javax.money.convert.ExchangeRate;
import javax.money.convert.ExchangeRateProvider;
import javax.money.convert.ProviderContext;
+import javax.money.convert.ProviderContextBuilder;
+import javax.money.convert.RateType;
+import org.javamoney.moneta.convert.ExchangeRateBuilder;
+import org.javamoney.moneta.spi.DefaultNumberValue;
+import org.javamoney.moneta.spi.LazyBoundCurrencyConversion;
/**
* Bonus exercise 2 for implementing a local CSV-based historical exchange rate provider with JSR-354.
@@ -14,8 +30,6 @@ import javax.money.convert.ProviderContext;
* {@code exchange-rates-hist.csv}.
*
* The lookup should use base currency, term currency, and a date passed in the query context.
- *
- * The implementation is intentionally incomplete and should be finished by making tests pass.
*/
public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
@@ -28,6 +42,10 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
* Query key type for historical lookups.
*/
public static final Class DATE_QUERY_KEY = LocalDate.class;
+ private static final ProviderContext CONTEXT =
+ ProviderContextBuilder.of("csv-historical-exchange-rate-provider", RateType.HISTORIC).build();
+
+ private final Map> ratesByPairAndDate;
/**
* Create a new provider instance.
@@ -35,6 +53,7 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
* The provider should load exchange rates from {@link #RESOURCE_NAME}.
*/
public CsvHistoricalExchangeRateProvider() {
+ this.ratesByPairAndDate = loadRates();
}
/**
@@ -44,7 +63,7 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public ProviderContext getContext() {
- throw new UnsupportedOperationException("TODO");
+ return CONTEXT;
}
/**
@@ -62,7 +81,27 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
- throw new UnsupportedOperationException("TODO");
+ LocalDate requestedDate = conversionQuery.get(DATE_QUERY_KEY);
+ if (requestedDate == null) {
+ return null;
+ }
+
+ CurrencyPair currencyPair = CurrencyPair.from(conversionQuery);
+ NavigableMap ratesByDate = ratesByPairAndDate.get(currencyPair);
+ if (ratesByDate == null) {
+ return null;
+ }
+
+ Map.Entry bestMatch = ratesByDate.floorEntry(requestedDate);
+ if (bestMatch == null) {
+ return null;
+ }
+
+ return new ExchangeRateBuilder(getContext().getProviderName(), RateType.HISTORIC)
+ .setBase(conversionQuery.getBaseCurrency())
+ .setTerm(conversionQuery.getCurrency())
+ .setFactor(DefaultNumberValue.of(bestMatch.getValue()))
+ .build();
}
/**
@@ -78,6 +117,61 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) {
- throw new UnsupportedOperationException("TODO");
+ if (getExchangeRate(conversionQuery) == null) {
+ return null;
+ }
+ ConversionContext conversionContext = ConversionContextBuilder.create(getContext(), RateType.HISTORIC).build();
+ return new LazyBoundCurrencyConversion(conversionQuery, this, conversionContext);
+ }
+
+ /**
+ * Load historical exchange rates from the classpath CSV resource into a date-indexed lookup map.
+ *
+ * The CSV is expected to contain a header and records in the format
+ * {@code date,base,term,rate}.
+ *
+ * @return map keyed by currency pair with a navigable date-to-rate mapping
+ */
+ private static Map> loadRates() {
+ InputStream inputStream = CsvHistoricalExchangeRateProvider.class.getClassLoader().getResourceAsStream(RESOURCE_NAME);
+ if (inputStream == null) {
+ throw new IllegalStateException("Could not load classpath resource: " + RESOURCE_NAME);
+ }
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
+ Map> rates = new HashMap<>();
+ reader.readLine();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ String[] values = line.split(",");
+ LocalDate date = LocalDate.parse(values[0]);
+ CurrencyPair currencyPair = new CurrencyPair(values[1], values[2]);
+ BigDecimal factor = new BigDecimal(values[3]);
+ rates.computeIfAbsent(currencyPair, key -> new TreeMap<>()).put(date, factor);
+ }
+ return rates;
+ } catch (Exception exception) {
+ throw new IllegalStateException("Failed to parse historical exchange rates from: " + RESOURCE_NAME, exception);
+ }
+ }
+
+ /**
+ * Simple key for identifying a historical exchange-rate series by base and term currency code.
+ *
+ * @param baseCurrencyCode ISO currency code of the base currency
+ * @param termCurrencyCode ISO currency code of the term currency
+ */
+ private record CurrencyPair(String baseCurrencyCode, String termCurrencyCode) {
+ /**
+ * Create a currency-pair key from a conversion query.
+ *
+ * @param conversionQuery query containing base and term currency
+ * @return key for map-based historical rate lookup
+ */
+ private static CurrencyPair from(ConversionQuery conversionQuery) {
+ return new CurrencyPair(
+ conversionQuery.getBaseCurrency().getCurrencyCode(),
+ conversionQuery.getCurrency().getCurrencyCode());
+ }
}
}
diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java
index 0dadb80..2d4c7c6 100644
--- a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java
+++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java
@@ -1,20 +1,32 @@
package swiss.fihlon.workshop.money.part4;
+import java.math.BigDecimal;
import javax.money.convert.ConversionQuery;
+import javax.money.convert.ConversionContext;
+import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.CurrencyConversion;
import javax.money.convert.ExchangeRate;
import javax.money.convert.ExchangeRateProvider;
import javax.money.convert.ProviderContext;
+import javax.money.convert.ProviderContextBuilder;
+import javax.money.convert.RateType;
+import org.javamoney.moneta.convert.ExchangeRateBuilder;
+import org.javamoney.moneta.spi.DefaultNumberValue;
+import org.javamoney.moneta.spi.LazyBoundCurrencyConversion;
/**
* Bonus exercise 3 for implementing local exchange rates between CHF and DukePoints.
*
* The provider should support deterministic conversion for {@code CHF -> DKP} and {@code DKP -> CHF}.
- *
- * The implementation is intentionally incomplete and should be finished by making tests pass.
*/
public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
+ private static final BigDecimal CHF_TO_DKP_FACTOR = new BigDecimal("0.1");
+ private static final BigDecimal DKP_TO_CHF_FACTOR = new BigDecimal("10");
+ private static final ProviderContext CONTEXT =
+ ProviderContextBuilder.of("dkp-exchange-rate-provider", RateType.HISTORIC).build();
+ private static final String CHF = "CHF";
+
/**
* Create a new exchange rate provider for DukePoints.
*/
@@ -28,7 +40,7 @@ public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public ProviderContext getContext() {
- throw new UnsupportedOperationException("TODO");
+ return CONTEXT;
}
/**
@@ -41,7 +53,19 @@ public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
- throw new UnsupportedOperationException("TODO");
+ String baseCurrencyCode = conversionQuery.getBaseCurrency().getCurrencyCode();
+ String termCurrencyCode = conversionQuery.getCurrency().getCurrencyCode();
+
+ BigDecimal factor = resolveFactor(baseCurrencyCode, termCurrencyCode);
+ if (factor == null) {
+ return null;
+ }
+
+ return new ExchangeRateBuilder(getContext().getProviderName(), RateType.HISTORIC)
+ .setBase(conversionQuery.getBaseCurrency())
+ .setTerm(conversionQuery.getCurrency())
+ .setFactor(DefaultNumberValue.of(factor))
+ .build();
}
/**
@@ -54,6 +78,27 @@ public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
*/
@Override
public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) {
- throw new UnsupportedOperationException("TODO");
+ if (getExchangeRate(conversionQuery) == null) {
+ return null;
+ }
+ ConversionContext conversionContext = ConversionContextBuilder.create(getContext(), RateType.HISTORIC).build();
+ return new LazyBoundCurrencyConversion(conversionQuery, this, conversionContext);
+ }
+
+ /**
+ * Resolve the conversion factor for a supported DukePoints currency pair.
+ *
+ * @param baseCurrencyCode currency code of the base currency
+ * @param termCurrencyCode currency code of the target currency
+ * @return conversion factor for supported pairs, otherwise {@code null}
+ */
+ private BigDecimal resolveFactor(String baseCurrencyCode, String termCurrencyCode) {
+ if (CHF.equals(baseCurrencyCode) && DukePointsCurrencyFactory.DKP.equals(termCurrencyCode)) {
+ return CHF_TO_DKP_FACTOR;
+ }
+ if (DukePointsCurrencyFactory.DKP.equals(baseCurrencyCode) && CHF.equals(termCurrencyCode)) {
+ return DKP_TO_CHF_FACTOR;
+ }
+ return null;
}
}