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; } }