From 4f915afb06b03e27297456f3f3cf14c650d79f8c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Sun, 19 Apr 2026 23:58:13 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(workshop):=20add=20reference=20impleme?= =?UTF-8?q?ntations=20for=20exercises=20(part1=E2=80=93part3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete solutions for all workshop exercises covering: - part1: basics (amounts, arithmetic, rounding, formatting, parsing) - part2: operators and queries - part3: integration scenarios Implementations follow JSR-354 best practices and align with exercise tests and workshop structure. --- .../money/part1/ArithmeticExercises.java | 8 ++-- .../money/part1/FormattingExercises.java | 23 ++++++++-- .../money/part1/IntegrationExercises.java | 28 ++++++++++++- .../workshop/money/part1/MoneyExercises.java | 11 +++-- .../money/part1/ParsingExercises.java | 32 ++++++++++++-- .../money/part1/RoundingExercises.java | 7 +++- .../part2/CurrencyConversionExercises.java | 32 ++------------ .../money/part2/MonetaryContextExercises.java | 9 ++-- .../part2/MonetaryOperatorExercises.java | 26 ++++++++---- .../money/part2/MonetaryQueryExercises.java | 16 +++---- .../part2/RoundingStrategyExercises.java | 42 ++++++++++--------- .../workshop/money/part3/OrderExercises.java | 31 +++++++++----- 12 files changed, 172 insertions(+), 93 deletions(-) diff --git a/src/main/java/swiss/fihlon/workshop/money/part1/ArithmeticExercises.java b/src/main/java/swiss/fihlon/workshop/money/part1/ArithmeticExercises.java index 1886e7e..c1c0b07 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part1/ArithmeticExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part1/ArithmeticExercises.java @@ -21,7 +21,7 @@ public class ArithmeticExercises { * @return sum of both amounts in the same currency */ public MonetaryAmount addAmounts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) { - throw new UnsupportedOperationException("TODO"); + return firstAmount.add(secondAmount); } /** @@ -34,7 +34,7 @@ public class ArithmeticExercises { * @return difference after subtraction in the same currency */ public MonetaryAmount subtractAmounts(MonetaryAmount minuend, MonetaryAmount subtrahend) { - throw new UnsupportedOperationException("TODO"); + return minuend.subtract(subtrahend); } /** @@ -47,7 +47,7 @@ public class ArithmeticExercises { * @return product amount in the same currency */ public MonetaryAmount multiplyAmount(MonetaryAmount amount, int factor) { - throw new UnsupportedOperationException("TODO"); + return amount.multiply(factor); } /** @@ -60,6 +60,6 @@ public class ArithmeticExercises { * @return {@code true} if first amount is greater, otherwise {@code false} */ public boolean compareAmounts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) { - throw new UnsupportedOperationException("TODO"); + return firstAmount.isGreaterThan(secondAmount); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part1/FormattingExercises.java b/src/main/java/swiss/fihlon/workshop/money/part1/FormattingExercises.java index b1fa413..202dc81 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part1/FormattingExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part1/FormattingExercises.java @@ -1,6 +1,9 @@ package swiss.fihlon.workshop.money.part1; +import java.util.Locale; import javax.money.MonetaryAmount; +import javax.money.format.MonetaryAmountFormat; +import javax.money.format.MonetaryFormats; /** *

Part 1 workshop exercises for formatting monetary amounts using JSR-354.

@@ -21,7 +24,7 @@ public class FormattingExercises { * @return formatted amount string for Swiss German locale */ public String formatSwissGerman(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return format(amount, "de-CH"); } /** @@ -33,7 +36,7 @@ public class FormattingExercises { * @return formatted amount string for German locale */ public String formatGerman(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return format(amount, "de-DE"); } /** @@ -45,6 +48,20 @@ public class FormattingExercises { * @return formatted amount string for US locale */ public String formatUs(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return format(amount, "en-US"); + } + + /** + *

Format the given amount using the specified locale.

+ * + *

Use the provided language tag to obtain a locale-specific formatter.

+ * + * @param amount the amount to format + * @param languageTag IETF BCP 47 language tag (e.g. {@code de-CH}) + * @return formatted amount string + */ + private String format(MonetaryAmount amount, String languageTag) { + MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(Locale.forLanguageTag(languageTag)); + return format.format(amount); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part1/IntegrationExercises.java b/src/main/java/swiss/fihlon/workshop/money/part1/IntegrationExercises.java index 9036e31..4d09217 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part1/IntegrationExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part1/IntegrationExercises.java @@ -1,5 +1,13 @@ package swiss.fihlon.workshop.money.part1; +import java.util.Locale; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.MonetaryOperator; +import javax.money.format.MonetaryAmountFormat; +import javax.money.format.MonetaryFormats; +import org.javamoney.moneta.Money; + /** *

Part 1 workshop exercises for combining parsing, arithmetic, rounding, and formatting with JSR-354.

* @@ -22,6 +30,24 @@ public class IntegrationExercises { * @return formatted final price text */ public String calculateFinalPrice(String priceText) { - throw new UnsupportedOperationException("TODO"); + // setup locale and formatter + Locale swissGerman = Locale.forLanguageTag("de-CH"); + MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(swissGerman); + + // parse + MonetaryAmount price = format.parse(priceText); + + // constants + MonetaryAmount shipping = Money.of(4.95, "CHF"); + MonetaryAmount discount = Money.of(2.00, "CHF"); + + // calculate + MonetaryAmount total = price.add(shipping).subtract(discount); + + // round + MonetaryAmount roundedTotal = total.with(Monetary.getDefaultRounding()); + + // format + return format.format(roundedTotal); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part1/MoneyExercises.java b/src/main/java/swiss/fihlon/workshop/money/part1/MoneyExercises.java index e63510e..28d6ddf 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part1/MoneyExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part1/MoneyExercises.java @@ -1,6 +1,9 @@ package swiss.fihlon.workshop.money.part1; +import org.javamoney.moneta.Money; + import javax.money.CurrencyUnit; +import javax.money.Monetary; import javax.money.MonetaryAmount; /** @@ -19,7 +22,7 @@ public class MoneyExercises { * @return Swiss franc currency unit ({@code CHF}) */ public CurrencyUnit createSwissFranc() { - throw new UnsupportedOperationException("TODO"); + return Monetary.getCurrency("CHF"); } /** @@ -29,7 +32,7 @@ public class MoneyExercises { * @return monetary amount {@code 19.95 CHF} */ public MonetaryAmount createSwissFrancAmount() { - throw new UnsupportedOperationException("TODO"); + return Money.of(19.95, "CHF"); } /** @@ -39,7 +42,7 @@ public class MoneyExercises { * @return monetary amount {@code 2500 JPY} */ public MonetaryAmount createJapaneseYenAmount() { - throw new UnsupportedOperationException("TODO"); + return Money.of(2500, "JPY"); } /** @@ -49,6 +52,6 @@ public class MoneyExercises { * @return monetary amount {@code 12.345 TND} */ public MonetaryAmount createTunisianDinarAmount() { - throw new UnsupportedOperationException("TODO"); + return Money.of(12.345, "TND"); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part1/ParsingExercises.java b/src/main/java/swiss/fihlon/workshop/money/part1/ParsingExercises.java index 301c76a..cb77428 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part1/ParsingExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part1/ParsingExercises.java @@ -1,6 +1,9 @@ package swiss.fihlon.workshop.money.part1; import javax.money.MonetaryAmount; +import javax.money.format.MonetaryAmountFormat; +import javax.money.format.MonetaryFormats; +import javax.money.format.MonetaryParseException; import java.util.Locale; /** @@ -24,7 +27,7 @@ public class ParsingExercises { * @return parsed monetary amount */ public MonetaryAmount parseSwissGerman(String text) { - throw new UnsupportedOperationException("TODO"); + return parse(text, "de-CH"); } /** @@ -36,7 +39,7 @@ public class ParsingExercises { * @return parsed monetary amount */ public MonetaryAmount parseGerman(String text) { - throw new UnsupportedOperationException("TODO"); + return parse(text, "de-DE"); } /** @@ -48,7 +51,22 @@ public class ParsingExercises { * @return parsed monetary amount */ public MonetaryAmount parseUs(String text) { - throw new UnsupportedOperationException("TODO"); + return parse(text, "en-US"); + } + + /** + *

Parse the given monetary amount text using the specified locale.

+ * + *

Use the provided language tag to obtain a locale-specific parser.

+ * + * @param text the input text to parse + * @param languageTag IETF BCP 47 language tag (e.g. {@code de-CH}) + * @return parsed monetary amount + */ + private MonetaryAmount parse(String text, String languageTag) { + MonetaryAmountFormat format = + MonetaryFormats.getAmountFormat(Locale.forLanguageTag(languageTag)); + return format.parse(text); } /** @@ -61,6 +79,12 @@ public class ParsingExercises { * @return {@code true} if parsing succeeds, otherwise {@code false} */ public boolean parseInvalidInput(String text, Locale locale) { - throw new UnsupportedOperationException("TODO"); + MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(locale); + try { + format.parse(text); + return true; + } catch (MonetaryParseException exception) { + return false; + } } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part1/RoundingExercises.java b/src/main/java/swiss/fihlon/workshop/money/part1/RoundingExercises.java index ac299e8..3e81ccf 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part1/RoundingExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part1/RoundingExercises.java @@ -1,6 +1,8 @@ package swiss.fihlon.workshop.money.part1; +import javax.money.Monetary; import javax.money.MonetaryAmount; +import javax.money.MonetaryOperator; /** *

Part 1 workshop exercises for currency-aware rounding with JSR-354.

@@ -20,7 +22,7 @@ public class RoundingExercises { * @return rounded amount */ public MonetaryAmount applyDefaultRounding(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return amount.with(Monetary.getDefaultRounding()); } /** @@ -33,6 +35,7 @@ public class RoundingExercises { * @return divided and rounded amount */ public MonetaryAmount divideAndRound(MonetaryAmount amount, int divisor) { - throw new UnsupportedOperationException("TODO"); + return amount.divide(divisor) + .with(Monetary.getDefaultRounding()); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java index 7fd4b9e..2173271 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java @@ -1,8 +1,8 @@ package swiss.fihlon.workshop.money.part2; -import java.time.LocalDateTime; import javax.money.MonetaryAmount; import javax.money.convert.CurrencyConversion; +import javax.money.convert.MonetaryConversions; /** *

Part 2 workshop exercises for currency conversion using JSR-354.

@@ -21,7 +21,7 @@ public class CurrencyConversionExercises { * @return currency conversion to EUR */ public CurrencyConversion getEurConversion() { - throw new UnsupportedOperationException("TODO"); + return MonetaryConversions.getConversion("EUR", "ECB"); } /** @@ -33,31 +33,7 @@ public class CurrencyConversionExercises { * @return converted amount in EUR */ public MonetaryAmount convertChfToEur(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); - } - - /** - *

Create a currency conversion to EUR using a timestamp-based conversion query.

- * - *

Include the given {@link LocalDateTime} in the query.

- * - * @param timestamp timestamp used in the conversion query - * @return currency conversion to EUR for the given timestamp - */ - public CurrencyConversion getEurConversionForTimestamp(LocalDateTime timestamp) { - throw new UnsupportedOperationException("TODO"); - } - - /** - *

Convert a CHF amount to EUR using a timestamp-based conversion query.

- * - *

Build the conversion query with the given timestamp and return the converted amount.

- * - * @param amount amount in CHF - * @param timestamp timestamp used in the conversion query - * @return converted amount in EUR - */ - public MonetaryAmount convertChfToEurAt(MonetaryAmount amount, LocalDateTime timestamp) { - throw new UnsupportedOperationException("TODO"); + CurrencyConversion conversion = getEurConversion(); + return amount.with(conversion); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryContextExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryContextExercises.java index 395a7be..eef2f2e 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryContextExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryContextExercises.java @@ -22,7 +22,7 @@ public class MonetaryContextExercises { * @return monetary context of the amount */ public MonetaryContext getMonetaryContext(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return amount.getContext(); } /** @@ -34,7 +34,7 @@ public class MonetaryContextExercises { * @return maximum scale from the monetary context */ public int getMaxScale(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return amount.getContext().getMaxScale(); } /** @@ -47,7 +47,7 @@ public class MonetaryContextExercises { * @return {@code true} if both contexts are equal, otherwise {@code false} */ public boolean compareContexts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) { - throw new UnsupportedOperationException("TODO"); + return firstAmount.getContext().equals(secondAmount.getContext()); } /** @@ -59,6 +59,7 @@ public class MonetaryContextExercises { * @return formatted context description */ public String describeContext(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryContext context = amount.getContext(); + return "precision=" + context.getPrecision() + ", maxScale=" + context.getMaxScale(); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryOperatorExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryOperatorExercises.java index 3147e8f..c2b9e30 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryOperatorExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryOperatorExercises.java @@ -1,14 +1,13 @@ package swiss.fihlon.workshop.money.part2; +import java.math.BigDecimal; import javax.money.MonetaryAmount; import javax.money.MonetaryOperator; /** *

Part 2 workshop exercises for encapsulating domain logic with MonetaryOperator.

* - *

The exercises focus on applying reusable monetary operations such as discounts and VAT.

- * - *

The methods are intentionally incomplete and should be implemented by making tests pass.

+ *

The examples focus on applying reusable monetary operations such as discounts and VAT.

*/ public class MonetaryOperatorExercises { @@ -21,7 +20,8 @@ public class MonetaryOperatorExercises { * @return discounted amount */ public MonetaryAmount applyDiscount(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryOperator discount = createDiscountOperator(); + return amount.with(discount); } /** @@ -33,7 +33,8 @@ public class MonetaryOperatorExercises { * @return amount after VAT */ public MonetaryAmount applyVat(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryOperator vat = createVatOperator(); + return amount.with(vat); } /** @@ -45,7 +46,18 @@ public class MonetaryOperatorExercises { * @return amount after discount and VAT */ public MonetaryAmount applyDiscountThenVat(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryOperator discount = createDiscountOperator(); + MonetaryOperator vat = createVatOperator(); + return amount.with(discount).with(vat); + } + + /** + *

Create a reusable 7.7% VAT monetary operator.

+ * + * @return a reusable VAT operator + */ + public MonetaryOperator createVatOperator() { + return monetaryAmount -> monetaryAmount.multiply(new BigDecimal("1.077")); } /** @@ -56,6 +68,6 @@ public class MonetaryOperatorExercises { * @return reusable discount operator */ public MonetaryOperator createDiscountOperator() { - throw new UnsupportedOperationException("TODO"); + return monetaryAmount -> monetaryAmount.multiply(new BigDecimal("0.90")); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryQueryExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryQueryExercises.java index 79d3d76..3a89380 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryQueryExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/MonetaryQueryExercises.java @@ -7,9 +7,7 @@ import javax.money.MonetaryQuery; /** *

Part 2 workshop exercises for extracting information from monetary amounts with MonetaryQuery.

* - *

The exercises focus on encapsulating reusable query logic for monetary values and currencies.

- * - *

The methods are intentionally incomplete and should be implemented by making tests pass.

+ *

The examples focus on encapsulating reusable query logic for monetary values and currencies.

*/ public class MonetaryQueryExercises { @@ -22,7 +20,7 @@ public class MonetaryQueryExercises { * @return currency code */ public String queryCurrencyCode(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + return amount.query(createCurrencyCodeQuery()); } /** @@ -34,7 +32,9 @@ public class MonetaryQueryExercises { * @return default fraction digits of the currency */ public int queryDefaultFractionDigits(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryQuery query = + monetaryAmount -> monetaryAmount.getCurrency().getDefaultFractionDigits(); + return amount.query(query); } /** @@ -46,7 +46,9 @@ public class MonetaryQueryExercises { * @return numeric value as BigDecimal */ public BigDecimal queryNumberAsBigDecimal(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryQuery query = + monetaryAmount -> monetaryAmount.getNumber().numberValueExact(BigDecimal.class); + return amount.query(query); } /** @@ -57,6 +59,6 @@ public class MonetaryQueryExercises { * @return reusable query returning a currency code */ public MonetaryQuery createCurrencyCodeQuery() { - throw new UnsupportedOperationException("TODO"); + return monetaryAmount -> monetaryAmount.getCurrency().getCurrencyCode(); } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java index 99f7615..e3bb88b 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java @@ -1,14 +1,15 @@ package swiss.fihlon.workshop.money.part2; +import java.math.BigDecimal; +import java.math.RoundingMode; +import javax.money.Monetary; import javax.money.MonetaryAmount; import javax.money.MonetaryOperator; /** - *

Part 2 workshop exercises for applying different rounding strategies with JSR-354.

+ *

Part 2 workshop examples for applying different rounding strategies with JSR-354.

* - *

The exercises focus on treating rounding as an explicit domain decision that depends on the use case.

- * - *

The methods are intentionally incomplete and should be implemented by making tests pass.

+ *

The examples focus on treating rounding as an explicit domain decision that depends on the use case.

*/ public class RoundingStrategyExercises { @@ -21,19 +22,8 @@ public class RoundingStrategyExercises { * @return amount rounded with default currency rounding */ public MonetaryAmount applyDefaultRounding(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); - } - - /** - *

Apply explicit default rounding to a VAT amount.

- * - *

Use rounding as a separate, explicit step.

- * - * @param taxAmount tax amount to round - * @return rounded tax amount - */ - public MonetaryAmount roundVatAmount(MonetaryAmount taxAmount) { - throw new UnsupportedOperationException("TODO"); + MonetaryOperator rounding = Monetary.getDefaultRounding(); + return amount.with(rounding); } /** @@ -45,7 +35,8 @@ public class RoundingStrategyExercises { * @return CHF amount rounded to the nearest 0.05 */ public MonetaryAmount applySwissCashRounding(MonetaryAmount amount) { - throw new UnsupportedOperationException("TODO"); + MonetaryOperator swissCashRounding = createSwissCashRounding(); + return amount.with(swissCashRounding); } /** @@ -56,6 +47,19 @@ public class RoundingStrategyExercises { * @return reusable Swiss cash rounding operator */ public MonetaryOperator createSwissCashRounding() { - throw new UnsupportedOperationException("TODO"); + return monetaryAmount -> { + if (!"CHF".equals(monetaryAmount.getCurrency().getCurrencyCode())) { + throw new IllegalArgumentException("Swiss cash rounding only supports CHF."); + } + + BigDecimal increment = new BigDecimal("0.05"); + BigDecimal value = monetaryAmount.getNumber().numberValueExact(BigDecimal.class); + BigDecimal rounded = value.divide(increment, 0, RoundingMode.HALF_UP).multiply(increment); + + return monetaryAmount.getFactory() + .setCurrency(monetaryAmount.getCurrency()) + .setNumber(rounded) + .create(); + }; } } diff --git a/src/main/java/swiss/fihlon/workshop/money/part3/OrderExercises.java b/src/main/java/swiss/fihlon/workshop/money/part3/OrderExercises.java index 3ada081..549cb6f 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part3/OrderExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part3/OrderExercises.java @@ -1,14 +1,19 @@ package swiss.fihlon.workshop.money.part3; +import java.math.BigDecimal; +import java.util.Locale; import javax.money.CurrencyUnit; +import javax.money.Monetary; import javax.money.MonetaryAmount; +import javax.money.MonetaryOperator; +import javax.money.convert.MonetaryConversions; +import javax.money.format.MonetaryAmountFormat; +import javax.money.format.MonetaryFormats; /** - *

Part 3 workshop exercises for integrating monetary calculations into a small order workflow.

+ *

Part 3 workshop examples for integrating monetary calculations into a small order workflow.

* - *

The exercises focus on combining subtotal calculation, discount, VAT, rounding, and formatting.

- * - *

The methods are intentionally incomplete and should be implemented by making tests pass.

+ *

The examples focus on combining subtotal calculation, discount, VAT, rounding, and formatting.

*/ public class OrderExercises { @@ -22,7 +27,7 @@ public class OrderExercises { * @return subtotal amount */ public MonetaryAmount calculateSubtotal(MonetaryAmount unitPrice, int quantity) { - throw new UnsupportedOperationException("TODO"); + return unitPrice.multiply(quantity); } /** @@ -34,7 +39,8 @@ public class OrderExercises { * @return discounted amount */ public MonetaryAmount applyDiscount(MonetaryAmount subtotal) { - throw new UnsupportedOperationException("TODO"); + MonetaryOperator discount = amount -> amount.multiply(new BigDecimal("0.90")); + return subtotal.with(discount); } /** @@ -46,7 +52,9 @@ public class OrderExercises { * @return VAT-inclusive amount */ public MonetaryAmount applyVat(MonetaryAmount discountedAmount) { - throw new UnsupportedOperationException("TODO"); + MonetaryAmount vatInclusiveAmount = discountedAmount.multiply(new BigDecimal("1.077")); + MonetaryOperator rounding = Monetary.getDefaultRounding(); + return vatInclusiveAmount.with(rounding); } /** @@ -59,7 +67,9 @@ public class OrderExercises { * @return final total amount */ public MonetaryAmount calculateTotal(MonetaryAmount unitPrice, int quantity) { - throw new UnsupportedOperationException("TODO"); + MonetaryAmount subtotal = calculateSubtotal(unitPrice, quantity); + MonetaryAmount discountedAmount = applyDiscount(subtotal); + return applyVat(discountedAmount); } /** @@ -71,7 +81,8 @@ public class OrderExercises { * @return formatted total string */ public String formatTotal(MonetaryAmount total) { - throw new UnsupportedOperationException("TODO"); + MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(Locale.forLanguageTag("de-CH")); + return format.format(total); } /** @@ -84,6 +95,6 @@ public class OrderExercises { * @return amount converted to the target currency */ public MonetaryAmount convertToCurrency(MonetaryAmount amount, CurrencyUnit targetCurrency) { - throw new UnsupportedOperationException("TODO"); + return amount.with(MonetaryConversions.getConversion(targetCurrency, "ECB")); } } From e7e86624147f6437102cc06b60af4b3d00f90781 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 00:15:42 +0200 Subject: [PATCH 2/8] refactor(workshop): remove redundant VAT rounding method roundVatAmount duplicated default rounding logic and did not demonstrate a distinct rounding use case. --- .../money/part2/RoundingStrategyExercises.java | 12 ------------ .../part2/RoundingStrategyExercisesTest.java | 18 ++++-------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java index 99f7615..03d3b2f 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercises.java @@ -24,18 +24,6 @@ public class RoundingStrategyExercises { throw new UnsupportedOperationException("TODO"); } - /** - *

Apply explicit default rounding to a VAT amount.

- * - *

Use rounding as a separate, explicit step.

- * - * @param taxAmount tax amount to round - * @return rounded tax amount - */ - public MonetaryAmount roundVatAmount(MonetaryAmount taxAmount) { - throw new UnsupportedOperationException("TODO"); - } - /** *

Apply Swiss cash rounding to the nearest 0.05 for CHF amounts.

* diff --git a/src/test/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercisesTest.java b/src/test/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercisesTest.java index 6904009..7e2c60e 100644 --- a/src/test/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercisesTest.java +++ b/src/test/java/swiss/fihlon/workshop/money/part2/RoundingStrategyExercisesTest.java @@ -1,11 +1,12 @@ package swiss.fihlon.workshop.money.part2; -import java.math.BigDecimal; -import javax.money.MonetaryAmount; -import javax.money.MonetaryOperator; import org.javamoney.moneta.Money; import org.junit.jupiter.api.Test; +import javax.money.MonetaryAmount; +import javax.money.MonetaryOperator; +import java.math.BigDecimal; + import static org.assertj.core.api.Assertions.assertThat; class RoundingStrategyExercisesTest { @@ -23,17 +24,6 @@ class RoundingStrategyExercisesTest { assertThat(result.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("10.02"); } - @Test - void shouldRoundVatAmount() { - MonetaryAmount taxAmount = Money.of(1.537, "CHF"); - - MonetaryAmount result = exercises.roundVatAmount(taxAmount); - - assertThat(result).isNotNull(); - assertThat(result.getCurrency().getCurrencyCode()).isEqualTo("CHF"); - assertThat(result.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("1.54"); - } - @Test void shouldApplySwissCashRounding() { MonetaryAmount firstAmount = Money.of(10.02, "CHF"); From e8162f3f178424c597e407429e387cdec0d31dc6 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 00:18:14 +0200 Subject: [PATCH 3/8] refactor(workshop): drop historical currency conversion exercises Remove tasks depending on ECB-HIST as historical rate queries are inconsistent and environment-dependent. --- .../part2/CurrencyConversionExercises.java | 26 ---------------- .../CurrencyConversionExercisesTest.java | 31 +++---------------- 2 files changed, 5 insertions(+), 52 deletions(-) diff --git a/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java b/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java index 7fd4b9e..945040b 100644 --- a/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java +++ b/src/main/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercises.java @@ -1,6 +1,5 @@ package swiss.fihlon.workshop.money.part2; -import java.time.LocalDateTime; import javax.money.MonetaryAmount; import javax.money.convert.CurrencyConversion; @@ -35,29 +34,4 @@ public class CurrencyConversionExercises { public MonetaryAmount convertChfToEur(MonetaryAmount amount) { throw new UnsupportedOperationException("TODO"); } - - /** - *

Create a currency conversion to EUR using a timestamp-based conversion query.

- * - *

Include the given {@link LocalDateTime} in the query.

- * - * @param timestamp timestamp used in the conversion query - * @return currency conversion to EUR for the given timestamp - */ - public CurrencyConversion getEurConversionForTimestamp(LocalDateTime timestamp) { - throw new UnsupportedOperationException("TODO"); - } - - /** - *

Convert a CHF amount to EUR using a timestamp-based conversion query.

- * - *

Build the conversion query with the given timestamp and return the converted amount.

- * - * @param amount amount in CHF - * @param timestamp timestamp used in the conversion query - * @return converted amount in EUR - */ - public MonetaryAmount convertChfToEurAt(MonetaryAmount amount, LocalDateTime timestamp) { - throw new UnsupportedOperationException("TODO"); - } } diff --git a/src/test/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercisesTest.java b/src/test/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercisesTest.java index 1ad9a00..f563807 100644 --- a/src/test/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercisesTest.java +++ b/src/test/java/swiss/fihlon/workshop/money/part2/CurrencyConversionExercisesTest.java @@ -1,13 +1,13 @@ package swiss.fihlon.workshop.money.part2; -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDateTime; -import javax.money.MonetaryAmount; -import javax.money.convert.CurrencyConversion; import org.javamoney.moneta.Money; import org.junit.jupiter.api.Test; +import javax.money.MonetaryAmount; +import javax.money.convert.CurrencyConversion; + +import static org.assertj.core.api.Assertions.assertThat; + class CurrencyConversionExercisesTest { private final CurrencyConversionExercises exercises = new CurrencyConversionExercises(); @@ -29,25 +29,4 @@ class CurrencyConversionExercisesTest { assertThat(result).isNotNull(); assertThat(result.getCurrency().getCurrencyCode()).isEqualTo("EUR"); } - - @Test - void shouldCreateEurConversionForTimestamp() { - LocalDateTime timestamp = LocalDateTime.of(2024, 1, 15, 10, 30); - - CurrencyConversion conversion = exercises.getEurConversionForTimestamp(timestamp); - - assertThat(conversion).isNotNull(); - assertThat(conversion.getCurrency().getCurrencyCode()).isEqualTo("EUR"); - } - - @Test - void shouldConvertChfToEurAtTimestamp() { - MonetaryAmount chfAmount = Money.of(10, "CHF"); - LocalDateTime timestamp = LocalDateTime.of(2024, 1, 15, 10, 30); - - MonetaryAmount result = exercises.convertChfToEurAt(chfAmount, timestamp); - - assertThat(result).isNotNull(); - assertThat(result.getCurrency().getCurrencyCode()).isEqualTo("EUR"); - } } From 50b6605285ba43cc683a2f15514d6027f9ab6d06 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 00:19:32 +0200 Subject: [PATCH 4/8] fix(slides): correct date type in historical exchange rate example Use `LocalDate` instead of `LocalDateTime` to align with Moneta/ECB-HIST expectations for date-based queries. --- slides/slides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slides/slides.md b/slides/slides.md index ee6efed..e92e2cd 100644 --- a/slides/slides.md +++ b/slides/slides.md @@ -676,7 +676,7 @@ MonetaryAmount amountCHF = Money.of(10, "CHF"); ConversionQuery query = ConversionQueryBuilder.of() .setTermCurrency("EUR") - .set(LocalDateTime.class, LocalDateTime.of(2026, 3, 13, 14, 3, 27)) + .set(LocalDate.class, LocalDate.of(2026, 3, 13)) .build(); CurrencyConversion conversion = MonetaryConversions.getConversion(query); From 59d23421041cec017ea325d5b47d1307f9ad6359 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 00:20:21 +0200 Subject: [PATCH 5/8] docs(slides): add Otavio Santana as JSR-354 spec lead --- slides/slides.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/slides/slides.md b/slides/slides.md index e92e2cd..bbdaf3b 100644 --- a/slides/slides.md +++ b/slides/slides.md @@ -234,8 +234,7 @@ Was ist JSR-354? - Spezifikation: Java Community Process (JCP) - Referenzimplementierung: Moneta - Entwickelt und gepflegt von der Java-Community -- Spec Lead JSR-354: Werner Keil -- Lead der Referenzimplementierung (Moneta): Anatole Tresch +- Spec Leads: Anatole Tresch, Werner Keil und Otavio Santana 👉 Open Source und gemeinschaftlich weiterentwickelt From 8e61f2d2e2993e77789d9a4eb4a0bae15c176222 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 10:01:47 +0200 Subject: [PATCH 6/8] feat(workshop): add bonus exercises for custom providers and currencies Introduce optional bonus tasks in part4 covering: - CSV-based exchange rate provider - historical exchange rate provider with date handling - custom currency (DKP) with bidirectional conversion Provide exercise scaffolding and tests for all scenarios. Signed-off-by: Marcus Fihlon --- slides/slides.md | 70 +++++++++++- .../money/part4/CsvExchangeRateProvider.java | 65 +++++++++++ .../CsvHistoricalExchangeRateProvider.java | 83 ++++++++++++++ .../part4/DukePointsCurrencyFactory.java | 25 +++++ .../money/part4/DukePointsCurrencyUnit.java | 65 +++++++++++ .../part4/DukePointsExchangeRateProvider.java | 59 ++++++++++ src/main/resources/exchange-rates-hist.csv | 19 ++++ src/main/resources/exchange-rates.csv | 9 ++ .../part4/CsvExchangeRateProviderTest.java | 93 +++++++++++++++ ...CsvHistoricalExchangeRateProviderTest.java | 106 ++++++++++++++++++ .../DukePointsExchangeRateProviderTest.java | 87 ++++++++++++++ 11 files changed, 677 insertions(+), 4 deletions(-) create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java create mode 100644 src/main/resources/exchange-rates-hist.csv create mode 100644 src/main/resources/exchange-rates.csv create mode 100644 src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java create mode 100644 src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java create mode 100644 src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java diff --git a/slides/slides.md b/slides/slides.md index bbdaf3b..56d5c6f 100644 --- a/slides/slides.md +++ b/slides/slides.md @@ -154,9 +154,11 @@ BigDecimal amount = new BigDecimal("10.123") // gültig oder nicht?
-- CHF: 2 Nachkommastellen -- JPY: keine Nachkommastellen -- TND: 3 Nachkommastellen +
    +
  • CHF: 2 Nachkommastellen
  • +
  • JPY: keine Nachkommastellen
  • +
  • TND: 3 Nachkommastellen
  • +
--- @@ -991,6 +993,66 @@ Warenkorb / Bestellung --- +# Bonusaufgabe 1 + +Eigener Exchange Rate Provider + +Package: `swiss.fihlon.workshop.money.part4` + +- Implementiere einen eigenen `ExchangeRateProvider` +- Lade Wechselkurse aus `exchange-rates.csv` +- CSV enthält: + - Quellwährung + - Zielwährung + - Wechselkurs +- Stelle Umrechnungen zwischen den Währungen bereit +- Noch **ohne** Historisierung + +Relevante Interfaces: `ExchangeRateProvider` + +👉 Ziel: eigener Provider mit lokal geladenen Kursen + +--- + +# Bonus 2 + +Historische Kurse + +Package: `swiss.fihlon.workshop.money.part4` + +- Erweitere deinen Provider aus Bonus 1 +- Verwende `exchange-rates-hist.csv` +- CSV enthält zusätzlich: + - Datum pro Wechselkurs +- Wähle den passenden Kurs für ein angefragtes Datum +- Fallback: sinnvoller Standard (z. B. letzter verfügbarer Kurs) + +Relevante Interfaces: `ExchangeRateProvider` + +👉 Ziel: zeitabhängige Wechselkurse unterstützen + +--- + +# Bonus 3 + +Eigene Währung + +Package: `swiss.fihlon.workshop.money.part4` + +- Implementiere die eigene Währung `DKP` +- Vorgegebener Umrechnungsfaktor: + - 10 CHF = 1 DKP +- Baue einen eigenen Converter +- Unterstütze: + - CHF → DKP + - DKP → CHF + +Relevante Interfaces: `CurrencyUnit` und `ExchangeRateProvider` + +👉 Ziel: eigene Währung ins Modell integrieren + +--- + # Voraussetzungen Java-Versionen @@ -1057,4 +1119,4 @@ layout: center layout: center --- -# **Danke!** +# Danke! diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java new file mode 100644 index 0000000..005f3e4 --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java @@ -0,0 +1,65 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.convert.ConversionQuery; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import javax.money.convert.ExchangeRateProvider; +import javax.money.convert.ProviderContext; + +/** + *

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 { + + /** + *

Name of the classpath CSV resource containing exchange rates.

+ */ + public static final String RESOURCE_NAME = "exchange-rates.csv"; + + /** + *

Create a new provider instance.

+ * + *

The provider should load exchange rates from {@link #RESOURCE_NAME}.

+ */ + public CsvExchangeRateProvider() { + } + + /** + *

Return the provider context for this exchange rate provider.

+ * + * @return provider context including a useful provider name + */ + @Override + public ProviderContext getContext() { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Look up an exchange rate for the given conversion query.

+ * + * @param conversionQuery conversion query containing base and term currency + * @return matching exchange rate, or {@code null} if no matching rate exists + */ + @Override + public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Create a currency conversion for the given conversion query.

+ * + *

The returned conversion should allow direct amount conversion via {@code amount.with(conversion)}.

+ * + * @param conversionQuery conversion query containing conversion details + * @return currency conversion for the query + */ + @Override + public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java new file mode 100644 index 0000000..320bbaa --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java @@ -0,0 +1,83 @@ +package swiss.fihlon.workshop.money.part4; + +import java.time.LocalDate; +import javax.money.convert.ConversionQuery; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import javax.money.convert.ExchangeRateProvider; +import javax.money.convert.ProviderContext; + +/** + *

Bonus exercise 2 for implementing a local CSV-based historical exchange rate provider with JSR-354.

+ * + *

The provider is intended for workshop usage and should read rates from the classpath resource + * {@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 { + + /** + *

Name of the classpath CSV resource containing historical exchange rates.

+ */ + public static final String RESOURCE_NAME = "exchange-rates-hist.csv"; + + /** + *

Query key type for historical lookups.

+ */ + public static final Class DATE_QUERY_KEY = LocalDate.class; + + /** + *

Create a new provider instance.

+ * + *

The provider should load exchange rates from {@link #RESOURCE_NAME}.

+ */ + public CsvHistoricalExchangeRateProvider() { + } + + /** + *

Return the provider context for this exchange rate provider.

+ * + * @return provider context including a useful provider name + */ + @Override + public ProviderContext getContext() { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Look up a historical exchange rate for the given conversion query.

+ * + *

The query is expected to contain a {@link LocalDate} under {@link #DATE_QUERY_KEY}.

+ * + *

If no exact match exists for the requested date, the provider should use the last available + * rate before that date.

+ * + *

The method should return {@code null} only when no suitable earlier rate exists.

+ * + * @param conversionQuery conversion query containing base currency, term currency, and date + * @return matching historical exchange rate with fallback to the latest earlier date, or {@code null} + */ + @Override + public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Create a currency conversion for the given conversion query.

+ * + *

The conversion should use the same lookup rules as {@link #getExchangeRate(ConversionQuery)}: + * exact date first, otherwise the last available earlier rate.

+ * + *

The returned conversion should allow direct amount conversion via {@code amount.with(conversion)}.

+ * + * @param conversionQuery conversion query containing conversion details + * @return currency conversion for the query + */ + @Override + public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java new file mode 100644 index 0000000..f6c60db --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java @@ -0,0 +1,25 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.CurrencyUnit; + +/** + *

Bonus exercise 3 helper for creating the custom DukePoints currency.

+ * + *

The goal is to model {@code DKP} as a custom {@link CurrencyUnit} that can be used with monetary amounts.

+ */ +public class DukePointsCurrencyFactory { + + /** + *

Currency code for DukePoints.

+ */ + public static final String DKP = "DKP"; + + /** + *

Create the custom DukePoints currency unit.

+ * + * @return custom currency unit with currency code {@code DKP} + */ + public CurrencyUnit createDkpCurrency() { + return new DukePointsCurrencyUnit(); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java new file mode 100644 index 0000000..aafbcb2 --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java @@ -0,0 +1,65 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.CurrencyContext; +import javax.money.CurrencyContextBuilder; +import javax.money.CurrencyUnit; + +/** + *

This is a minimal custom CurrencyUnit implementation used for workshop purposes.

+ * + *

The implementation is provided to focus on currency integration and conversion logic.

+ */ +public final class DukePointsCurrencyUnit implements CurrencyUnit { + + private static final String CURRENCY_CODE = DukePointsCurrencyFactory.DKP; + private static final int DEFAULT_FRACTION_DIGITS = 2; + private static final int NUMERIC_CODE = 999; + private static final CurrencyContext CONTEXT = + CurrencyContextBuilder.of(DukePointsCurrencyUnit.class.getSimpleName()).build(); + + @Override + public String getCurrencyCode() { + return CURRENCY_CODE; + } + + @Override + public int getNumericCode() { + return NUMERIC_CODE; + } + + @Override + public int getDefaultFractionDigits() { + return DEFAULT_FRACTION_DIGITS; + } + + @Override + public CurrencyContext getContext() { + return CONTEXT; + } + + @Override + public int compareTo(CurrencyUnit otherCurrency) { + return getCurrencyCode().compareTo(otherCurrency.getCurrencyCode()); + } + + @Override + public String toString() { + return getCurrencyCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof CurrencyUnit other)) { + return false; + } + return getCurrencyCode().equals(other.getCurrencyCode()); + } + + @Override + public int hashCode() { + return getCurrencyCode().hashCode(); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java new file mode 100644 index 0000000..0dadb80 --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java @@ -0,0 +1,59 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.convert.ConversionQuery; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import javax.money.convert.ExchangeRateProvider; +import javax.money.convert.ProviderContext; + +/** + *

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 { + + /** + *

Create a new exchange rate provider for DukePoints.

+ */ + public DukePointsExchangeRateProvider() { + } + + /** + *

Return provider context metadata for this provider.

+ * + * @return provider context with a useful provider name + */ + @Override + public ProviderContext getContext() { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Return the exchange rate for the given conversion query.

+ * + *

Supported pairs are {@code CHF -> DKP} and {@code DKP -> CHF}.

+ * + * @param conversionQuery query containing base and term currency + * @return matching exchange rate, or {@code null} for unsupported currency pairs + */ + @Override + public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Create a currency conversion for the given conversion query.

+ * + *

The returned conversion should allow direct conversion using {@code amount.with(conversion)}.

+ * + * @param conversionQuery query containing base and term currency + * @return currency conversion based on the local DukePoints rules + */ + @Override + public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } +} diff --git a/src/main/resources/exchange-rates-hist.csv b/src/main/resources/exchange-rates-hist.csv new file mode 100644 index 0000000..d5b718b --- /dev/null +++ b/src/main/resources/exchange-rates-hist.csv @@ -0,0 +1,19 @@ +date,base,term,rate +2026-04-15,CHF,EUR,0.92 +2026-04-15,CHF,USD,1.09 +2026-04-15,EUR,CHF,1.09 +2026-04-15,EUR,USD,1.18 +2026-04-15,USD,CHF,0.92 +2026-04-15,USD,EUR,0.85 +2026-04-16,CHF,EUR,0.93 +2026-04-16,CHF,USD,1.10 +2026-04-16,EUR,CHF,1.08 +2026-04-16,EUR,USD,1.19 +2026-04-16,USD,CHF,0.91 +2026-04-16,USD,EUR,0.84 +2026-04-17,CHF,EUR,0.94 +2026-04-17,CHF,USD,1.11 +2026-04-17,EUR,CHF,1.06 +2026-04-17,EUR,USD,1.20 +2026-04-17,USD,CHF,0.90 +2026-04-17,USD,EUR,0.83 diff --git a/src/main/resources/exchange-rates.csv b/src/main/resources/exchange-rates.csv new file mode 100644 index 0000000..f9807ea --- /dev/null +++ b/src/main/resources/exchange-rates.csv @@ -0,0 +1,9 @@ +base,term,rate +CHF,EUR,0.93 +CHF,USD,1.10 +CHF,GBP,0.82 +EUR,CHF,1.08 +USD,CHF,0.91 +GBP,CHF,1.22 +EUR,USD,1.18 +USD,EUR,0.85 diff --git a/src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java b/src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java new file mode 100644 index 0000000..b972ea1 --- /dev/null +++ b/src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java @@ -0,0 +1,93 @@ +package swiss.fihlon.workshop.money.part4; + +import java.math.BigDecimal; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.convert.ConversionQuery; +import javax.money.convert.ConversionQueryBuilder; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import org.javamoney.moneta.Money; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CsvExchangeRateProviderTest { + + private final CsvExchangeRateProvider provider = new CsvExchangeRateProvider(); + + @Test + void shouldReturnProviderContextWithUsefulName() { + String providerName = provider.getContext().getProviderName(); + + assertThat(providerName).isNotBlank(); + assertThat(providerName).containsIgnoringCase("csv"); + } + + @Test + void shouldReturnExchangeRateForChfToEur() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.93"); + } + + @Test + void shouldReturnExchangeRateForUsdToEur() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("USD")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.85"); + } + + @Test + void shouldReturnNullForUnknownCurrencyPair() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("EUR")) + .setTermCurrency(Monetary.getCurrency("GBP")) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNull(); + } + + @Test + void shouldConvertAmountUsingExchangeRate() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + ExchangeRate exchangeRate = provider.getExchangeRate(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("93.00"); + } + + @Test + void shouldCreateCurrencyConversion() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + + CurrencyConversion conversion = provider.getCurrencyConversion(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + MonetaryAmount convertedAmount = chfAmount.with(conversion); + + assertThat(convertedAmount.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("93.00"); + } +} diff --git a/src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java b/src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java new file mode 100644 index 0000000..09fe58a --- /dev/null +++ b/src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java @@ -0,0 +1,106 @@ +package swiss.fihlon.workshop.money.part4; + +import java.math.BigDecimal; +import java.time.LocalDate; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.convert.ConversionQuery; +import javax.money.convert.ConversionQueryBuilder; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import org.javamoney.moneta.Money; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CsvHistoricalExchangeRateProviderTest { + + private final CsvHistoricalExchangeRateProvider provider = new CsvHistoricalExchangeRateProvider(); + + @Test + void shouldReturnExchangeRateForChfToEurOn20260415() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .set(LocalDate.class, LocalDate.of(2026, 4, 15)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.92"); + } + + @Test + void shouldReturnExchangeRateForChfToEurOn20260417() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .set(LocalDate.class, LocalDate.of(2026, 4, 17)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.94"); + } + + @Test + void shouldReturnExchangeRateForUsdToChfOn20260416() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("USD")) + .setTermCurrency(Monetary.getCurrency("CHF")) + .set(LocalDate.class, LocalDate.of(2026, 4, 16)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.91"); + } + + @Test + void shouldReturnLastAvailableRateWhenDateIsMissing() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .set(LocalDate.class, LocalDate.of(2026, 4, 18)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.94"); + } + + @Test + void shouldConvertAmountUsingHistoricalRate() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("USD")) + .set(LocalDate.class, LocalDate.of(2026, 4, 17)) + .build(); + ExchangeRate exchangeRate = provider.getExchangeRate(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("111.00"); + } + + @Test + void shouldCreateCurrencyConversionForHistoricalRate() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("USD")) + .set(LocalDate.class, LocalDate.of(2026, 4, 17)) + .build(); + + CurrencyConversion conversion = provider.getCurrencyConversion(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + MonetaryAmount convertedAmount = chfAmount.with(conversion); + + assertThat(convertedAmount.getCurrency().getCurrencyCode()).isEqualTo("USD"); + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("111.00"); + } +} diff --git a/src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java b/src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java new file mode 100644 index 0000000..d8f73ab --- /dev/null +++ b/src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java @@ -0,0 +1,87 @@ +package swiss.fihlon.workshop.money.part4; + +import java.math.BigDecimal; +import javax.money.CurrencyUnit; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.convert.ConversionQuery; +import javax.money.convert.ConversionQueryBuilder; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import org.javamoney.moneta.Money; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DukePointsExchangeRateProviderTest { + + private final DukePointsCurrencyFactory currencyFactory = new DukePointsCurrencyFactory(); + private final DukePointsExchangeRateProvider exchangeRateProvider = new DukePointsExchangeRateProvider(); + + @Test + void shouldCreateDkpCurrency() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + + assertThat(dkp).isNotNull(); + assertThat(dkp.getCurrencyCode()).isEqualTo("DKP"); + } + + @Test + void shouldConvertChfToDkp() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(dkp) + .build(); + ExchangeRate exchangeRate = exchangeRateProvider.getExchangeRate(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("10.00"); + } + + @Test + void shouldConvertDkpToChf() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(dkp) + .setTermCurrency(Monetary.getCurrency("CHF")) + .build(); + ExchangeRate exchangeRate = exchangeRateProvider.getExchangeRate(query); + MonetaryAmount dkpAmount = Money.of(5, dkp); + + MonetaryAmount convertedAmount = dkpAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("50.00"); + } + + @Test + void shouldReturnNullForUnsupportedCurrencyPair() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("EUR")) + .setTermCurrency(dkp) + .build(); + + ExchangeRate exchangeRate = exchangeRateProvider.getExchangeRate(query); + + assertThat(exchangeRate).isNull(); + } + + @Test + void shouldConvertUsingCurrencyConversion() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(dkp) + .build(); + CurrencyConversion conversion = exchangeRateProvider.getCurrencyConversion(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.with(conversion); + + assertThat(convertedAmount.getCurrency().getCurrencyCode()).isEqualTo("DKP"); + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("10.00"); + } +} From f307005c6d797f54ffd39bf4f335be5deff2dc90 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 10:04:51 +0200 Subject: [PATCH 7/8] feat(workshop): add bonus exercises for custom providers and currencies Introduce optional bonus tasks in part4 covering: - CSV-based exchange rate provider - historical exchange rate provider with date handling - custom currency (DKP) with bidirectional conversion Provide exercise scaffolding and tests for all scenarios. Signed-off-by: Marcus Fihlon --- slides/slides.md | 75 +++++++++++-- .../money/part4/CsvExchangeRateProvider.java | 65 +++++++++++ .../CsvHistoricalExchangeRateProvider.java | 83 ++++++++++++++ .../part4/DukePointsCurrencyFactory.java | 25 +++++ .../money/part4/DukePointsCurrencyUnit.java | 65 +++++++++++ .../part4/DukePointsExchangeRateProvider.java | 59 ++++++++++ src/main/resources/exchange-rates-hist.csv | 19 ++++ src/main/resources/exchange-rates.csv | 9 ++ .../part4/CsvExchangeRateProviderTest.java | 93 +++++++++++++++ ...CsvHistoricalExchangeRateProviderTest.java | 106 ++++++++++++++++++ .../DukePointsExchangeRateProviderTest.java | 87 ++++++++++++++ 11 files changed, 679 insertions(+), 7 deletions(-) create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java create mode 100644 src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java create mode 100644 src/main/resources/exchange-rates-hist.csv create mode 100644 src/main/resources/exchange-rates.csv create mode 100644 src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java create mode 100644 src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java create mode 100644 src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java diff --git a/slides/slides.md b/slides/slides.md index ee6efed..56d5c6f 100644 --- a/slides/slides.md +++ b/slides/slides.md @@ -154,9 +154,11 @@ BigDecimal amount = new BigDecimal("10.123") // gĂĽltig oder nicht?
-- CHF: 2 Nachkommastellen -- JPY: keine Nachkommastellen -- TND: 3 Nachkommastellen +
    +
  • CHF: 2 Nachkommastellen
  • +
  • JPY: keine Nachkommastellen
  • +
  • TND: 3 Nachkommastellen
  • +
--- @@ -234,8 +236,7 @@ Was ist JSR-354? - Spezifikation: Java Community Process (JCP) - Referenzimplementierung: Moneta - Entwickelt und gepflegt von der Java-Community -- Spec Lead JSR-354: Werner Keil -- Lead der Referenzimplementierung (Moneta): Anatole Tresch +- Spec Leads: Anatole Tresch, Werner Keil und Otavio Santana 👉 Open Source und gemeinschaftlich weiterentwickelt @@ -676,7 +677,7 @@ MonetaryAmount amountCHF = Money.of(10, "CHF"); ConversionQuery query = ConversionQueryBuilder.of() .setTermCurrency("EUR") - .set(LocalDateTime.class, LocalDateTime.of(2026, 3, 13, 14, 3, 27)) + .set(LocalDate.class, LocalDate.of(2026, 3, 13)) .build(); CurrencyConversion conversion = MonetaryConversions.getConversion(query); @@ -992,6 +993,66 @@ Warenkorb / Bestellung --- +# Bonusaufgabe 1 + +Eigener Exchange Rate Provider + +Package: `swiss.fihlon.workshop.money.part4` + +- Implementiere einen eigenen `ExchangeRateProvider` +- Lade Wechselkurse aus `exchange-rates.csv` +- CSV enthält: + - Quellwährung + - Zielwährung + - Wechselkurs +- Stelle Umrechnungen zwischen den Währungen bereit +- Noch **ohne** Historisierung + +Relevante Interfaces: `ExchangeRateProvider` + +👉 Ziel: eigener Provider mit lokal geladenen Kursen + +--- + +# Bonus 2 + +Historische Kurse + +Package: `swiss.fihlon.workshop.money.part4` + +- Erweitere deinen Provider aus Bonus 1 +- Verwende `exchange-rates-hist.csv` +- CSV enthält zusätzlich: + - Datum pro Wechselkurs +- Wähle den passenden Kurs für ein angefragtes Datum +- Fallback: sinnvoller Standard (z. B. letzter verfügbarer Kurs) + +Relevante Interfaces: `ExchangeRateProvider` + +👉 Ziel: zeitabhängige Wechselkurse unterstützen + +--- + +# Bonus 3 + +Eigene Währung + +Package: `swiss.fihlon.workshop.money.part4` + +- Implementiere die eigene Währung `DKP` +- Vorgegebener Umrechnungsfaktor: + - 10 CHF = 1 DKP +- Baue einen eigenen Converter +- Unterstütze: + - CHF → DKP + - DKP → CHF + +Relevante Interfaces: `CurrencyUnit` und `ExchangeRateProvider` + +👉 Ziel: eigene Währung ins Modell integrieren + +--- + # Voraussetzungen Java-Versionen @@ -1058,4 +1119,4 @@ layout: center layout: center --- -# **Danke!** +# Danke! diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java new file mode 100644 index 0000000..005f3e4 --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProvider.java @@ -0,0 +1,65 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.convert.ConversionQuery; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import javax.money.convert.ExchangeRateProvider; +import javax.money.convert.ProviderContext; + +/** + *

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 { + + /** + *

Name of the classpath CSV resource containing exchange rates.

+ */ + public static final String RESOURCE_NAME = "exchange-rates.csv"; + + /** + *

Create a new provider instance.

+ * + *

The provider should load exchange rates from {@link #RESOURCE_NAME}.

+ */ + public CsvExchangeRateProvider() { + } + + /** + *

Return the provider context for this exchange rate provider.

+ * + * @return provider context including a useful provider name + */ + @Override + public ProviderContext getContext() { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Look up an exchange rate for the given conversion query.

+ * + * @param conversionQuery conversion query containing base and term currency + * @return matching exchange rate, or {@code null} if no matching rate exists + */ + @Override + public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Create a currency conversion for the given conversion query.

+ * + *

The returned conversion should allow direct amount conversion via {@code amount.with(conversion)}.

+ * + * @param conversionQuery conversion query containing conversion details + * @return currency conversion for the query + */ + @Override + public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java new file mode 100644 index 0000000..320bbaa --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProvider.java @@ -0,0 +1,83 @@ +package swiss.fihlon.workshop.money.part4; + +import java.time.LocalDate; +import javax.money.convert.ConversionQuery; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import javax.money.convert.ExchangeRateProvider; +import javax.money.convert.ProviderContext; + +/** + *

Bonus exercise 2 for implementing a local CSV-based historical exchange rate provider with JSR-354.

+ * + *

The provider is intended for workshop usage and should read rates from the classpath resource + * {@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 { + + /** + *

Name of the classpath CSV resource containing historical exchange rates.

+ */ + public static final String RESOURCE_NAME = "exchange-rates-hist.csv"; + + /** + *

Query key type for historical lookups.

+ */ + public static final Class DATE_QUERY_KEY = LocalDate.class; + + /** + *

Create a new provider instance.

+ * + *

The provider should load exchange rates from {@link #RESOURCE_NAME}.

+ */ + public CsvHistoricalExchangeRateProvider() { + } + + /** + *

Return the provider context for this exchange rate provider.

+ * + * @return provider context including a useful provider name + */ + @Override + public ProviderContext getContext() { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Look up a historical exchange rate for the given conversion query.

+ * + *

The query is expected to contain a {@link LocalDate} under {@link #DATE_QUERY_KEY}.

+ * + *

If no exact match exists for the requested date, the provider should use the last available + * rate before that date.

+ * + *

The method should return {@code null} only when no suitable earlier rate exists.

+ * + * @param conversionQuery conversion query containing base currency, term currency, and date + * @return matching historical exchange rate with fallback to the latest earlier date, or {@code null} + */ + @Override + public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Create a currency conversion for the given conversion query.

+ * + *

The conversion should use the same lookup rules as {@link #getExchangeRate(ConversionQuery)}: + * exact date first, otherwise the last available earlier rate.

+ * + *

The returned conversion should allow direct amount conversion via {@code amount.with(conversion)}.

+ * + * @param conversionQuery conversion query containing conversion details + * @return currency conversion for the query + */ + @Override + public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java new file mode 100644 index 0000000..f6c60db --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyFactory.java @@ -0,0 +1,25 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.CurrencyUnit; + +/** + *

Bonus exercise 3 helper for creating the custom DukePoints currency.

+ * + *

The goal is to model {@code DKP} as a custom {@link CurrencyUnit} that can be used with monetary amounts.

+ */ +public class DukePointsCurrencyFactory { + + /** + *

Currency code for DukePoints.

+ */ + public static final String DKP = "DKP"; + + /** + *

Create the custom DukePoints currency unit.

+ * + * @return custom currency unit with currency code {@code DKP} + */ + public CurrencyUnit createDkpCurrency() { + return new DukePointsCurrencyUnit(); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java new file mode 100644 index 0000000..aafbcb2 --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsCurrencyUnit.java @@ -0,0 +1,65 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.CurrencyContext; +import javax.money.CurrencyContextBuilder; +import javax.money.CurrencyUnit; + +/** + *

This is a minimal custom CurrencyUnit implementation used for workshop purposes.

+ * + *

The implementation is provided to focus on currency integration and conversion logic.

+ */ +public final class DukePointsCurrencyUnit implements CurrencyUnit { + + private static final String CURRENCY_CODE = DukePointsCurrencyFactory.DKP; + private static final int DEFAULT_FRACTION_DIGITS = 2; + private static final int NUMERIC_CODE = 999; + private static final CurrencyContext CONTEXT = + CurrencyContextBuilder.of(DukePointsCurrencyUnit.class.getSimpleName()).build(); + + @Override + public String getCurrencyCode() { + return CURRENCY_CODE; + } + + @Override + public int getNumericCode() { + return NUMERIC_CODE; + } + + @Override + public int getDefaultFractionDigits() { + return DEFAULT_FRACTION_DIGITS; + } + + @Override + public CurrencyContext getContext() { + return CONTEXT; + } + + @Override + public int compareTo(CurrencyUnit otherCurrency) { + return getCurrencyCode().compareTo(otherCurrency.getCurrencyCode()); + } + + @Override + public String toString() { + return getCurrencyCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof CurrencyUnit other)) { + return false; + } + return getCurrencyCode().equals(other.getCurrencyCode()); + } + + @Override + public int hashCode() { + return getCurrencyCode().hashCode(); + } +} diff --git a/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java new file mode 100644 index 0000000..0dadb80 --- /dev/null +++ b/src/main/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProvider.java @@ -0,0 +1,59 @@ +package swiss.fihlon.workshop.money.part4; + +import javax.money.convert.ConversionQuery; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import javax.money.convert.ExchangeRateProvider; +import javax.money.convert.ProviderContext; + +/** + *

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 { + + /** + *

Create a new exchange rate provider for DukePoints.

+ */ + public DukePointsExchangeRateProvider() { + } + + /** + *

Return provider context metadata for this provider.

+ * + * @return provider context with a useful provider name + */ + @Override + public ProviderContext getContext() { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Return the exchange rate for the given conversion query.

+ * + *

Supported pairs are {@code CHF -> DKP} and {@code DKP -> CHF}.

+ * + * @param conversionQuery query containing base and term currency + * @return matching exchange rate, or {@code null} for unsupported currency pairs + */ + @Override + public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } + + /** + *

Create a currency conversion for the given conversion query.

+ * + *

The returned conversion should allow direct conversion using {@code amount.with(conversion)}.

+ * + * @param conversionQuery query containing base and term currency + * @return currency conversion based on the local DukePoints rules + */ + @Override + public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { + throw new UnsupportedOperationException("TODO"); + } +} diff --git a/src/main/resources/exchange-rates-hist.csv b/src/main/resources/exchange-rates-hist.csv new file mode 100644 index 0000000..d5b718b --- /dev/null +++ b/src/main/resources/exchange-rates-hist.csv @@ -0,0 +1,19 @@ +date,base,term,rate +2026-04-15,CHF,EUR,0.92 +2026-04-15,CHF,USD,1.09 +2026-04-15,EUR,CHF,1.09 +2026-04-15,EUR,USD,1.18 +2026-04-15,USD,CHF,0.92 +2026-04-15,USD,EUR,0.85 +2026-04-16,CHF,EUR,0.93 +2026-04-16,CHF,USD,1.10 +2026-04-16,EUR,CHF,1.08 +2026-04-16,EUR,USD,1.19 +2026-04-16,USD,CHF,0.91 +2026-04-16,USD,EUR,0.84 +2026-04-17,CHF,EUR,0.94 +2026-04-17,CHF,USD,1.11 +2026-04-17,EUR,CHF,1.06 +2026-04-17,EUR,USD,1.20 +2026-04-17,USD,CHF,0.90 +2026-04-17,USD,EUR,0.83 diff --git a/src/main/resources/exchange-rates.csv b/src/main/resources/exchange-rates.csv new file mode 100644 index 0000000..f9807ea --- /dev/null +++ b/src/main/resources/exchange-rates.csv @@ -0,0 +1,9 @@ +base,term,rate +CHF,EUR,0.93 +CHF,USD,1.10 +CHF,GBP,0.82 +EUR,CHF,1.08 +USD,CHF,0.91 +GBP,CHF,1.22 +EUR,USD,1.18 +USD,EUR,0.85 diff --git a/src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java b/src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java new file mode 100644 index 0000000..b972ea1 --- /dev/null +++ b/src/test/java/swiss/fihlon/workshop/money/part4/CsvExchangeRateProviderTest.java @@ -0,0 +1,93 @@ +package swiss.fihlon.workshop.money.part4; + +import java.math.BigDecimal; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.convert.ConversionQuery; +import javax.money.convert.ConversionQueryBuilder; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import org.javamoney.moneta.Money; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CsvExchangeRateProviderTest { + + private final CsvExchangeRateProvider provider = new CsvExchangeRateProvider(); + + @Test + void shouldReturnProviderContextWithUsefulName() { + String providerName = provider.getContext().getProviderName(); + + assertThat(providerName).isNotBlank(); + assertThat(providerName).containsIgnoringCase("csv"); + } + + @Test + void shouldReturnExchangeRateForChfToEur() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.93"); + } + + @Test + void shouldReturnExchangeRateForUsdToEur() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("USD")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.85"); + } + + @Test + void shouldReturnNullForUnknownCurrencyPair() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("EUR")) + .setTermCurrency(Monetary.getCurrency("GBP")) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNull(); + } + + @Test + void shouldConvertAmountUsingExchangeRate() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + ExchangeRate exchangeRate = provider.getExchangeRate(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("93.00"); + } + + @Test + void shouldCreateCurrencyConversion() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .build(); + + CurrencyConversion conversion = provider.getCurrencyConversion(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + MonetaryAmount convertedAmount = chfAmount.with(conversion); + + assertThat(convertedAmount.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("93.00"); + } +} diff --git a/src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java b/src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java new file mode 100644 index 0000000..09fe58a --- /dev/null +++ b/src/test/java/swiss/fihlon/workshop/money/part4/CsvHistoricalExchangeRateProviderTest.java @@ -0,0 +1,106 @@ +package swiss.fihlon.workshop.money.part4; + +import java.math.BigDecimal; +import java.time.LocalDate; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.convert.ConversionQuery; +import javax.money.convert.ConversionQueryBuilder; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import org.javamoney.moneta.Money; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CsvHistoricalExchangeRateProviderTest { + + private final CsvHistoricalExchangeRateProvider provider = new CsvHistoricalExchangeRateProvider(); + + @Test + void shouldReturnExchangeRateForChfToEurOn20260415() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .set(LocalDate.class, LocalDate.of(2026, 4, 15)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.92"); + } + + @Test + void shouldReturnExchangeRateForChfToEurOn20260417() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .set(LocalDate.class, LocalDate.of(2026, 4, 17)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.94"); + } + + @Test + void shouldReturnExchangeRateForUsdToChfOn20260416() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("USD")) + .setTermCurrency(Monetary.getCurrency("CHF")) + .set(LocalDate.class, LocalDate.of(2026, 4, 16)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.91"); + } + + @Test + void shouldReturnLastAvailableRateWhenDateIsMissing() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("EUR")) + .set(LocalDate.class, LocalDate.of(2026, 4, 18)) + .build(); + + ExchangeRate exchangeRate = provider.getExchangeRate(query); + + assertThat(exchangeRate).isNotNull(); + assertThat(exchangeRate.getFactor().numberValueExact(BigDecimal.class)).isEqualByComparingTo("0.94"); + } + + @Test + void shouldConvertAmountUsingHistoricalRate() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("USD")) + .set(LocalDate.class, LocalDate.of(2026, 4, 17)) + .build(); + ExchangeRate exchangeRate = provider.getExchangeRate(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("111.00"); + } + + @Test + void shouldCreateCurrencyConversionForHistoricalRate() { + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(Monetary.getCurrency("USD")) + .set(LocalDate.class, LocalDate.of(2026, 4, 17)) + .build(); + + CurrencyConversion conversion = provider.getCurrencyConversion(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + MonetaryAmount convertedAmount = chfAmount.with(conversion); + + assertThat(convertedAmount.getCurrency().getCurrencyCode()).isEqualTo("USD"); + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("111.00"); + } +} diff --git a/src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java b/src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java new file mode 100644 index 0000000..d8f73ab --- /dev/null +++ b/src/test/java/swiss/fihlon/workshop/money/part4/DukePointsExchangeRateProviderTest.java @@ -0,0 +1,87 @@ +package swiss.fihlon.workshop.money.part4; + +import java.math.BigDecimal; +import javax.money.CurrencyUnit; +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import javax.money.convert.ConversionQuery; +import javax.money.convert.ConversionQueryBuilder; +import javax.money.convert.CurrencyConversion; +import javax.money.convert.ExchangeRate; +import org.javamoney.moneta.Money; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DukePointsExchangeRateProviderTest { + + private final DukePointsCurrencyFactory currencyFactory = new DukePointsCurrencyFactory(); + private final DukePointsExchangeRateProvider exchangeRateProvider = new DukePointsExchangeRateProvider(); + + @Test + void shouldCreateDkpCurrency() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + + assertThat(dkp).isNotNull(); + assertThat(dkp.getCurrencyCode()).isEqualTo("DKP"); + } + + @Test + void shouldConvertChfToDkp() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(dkp) + .build(); + ExchangeRate exchangeRate = exchangeRateProvider.getExchangeRate(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("10.00"); + } + + @Test + void shouldConvertDkpToChf() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(dkp) + .setTermCurrency(Monetary.getCurrency("CHF")) + .build(); + ExchangeRate exchangeRate = exchangeRateProvider.getExchangeRate(query); + MonetaryAmount dkpAmount = Money.of(5, dkp); + + MonetaryAmount convertedAmount = dkpAmount.multiply(exchangeRate.getFactor().numberValueExact(BigDecimal.class)); + + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("50.00"); + } + + @Test + void shouldReturnNullForUnsupportedCurrencyPair() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("EUR")) + .setTermCurrency(dkp) + .build(); + + ExchangeRate exchangeRate = exchangeRateProvider.getExchangeRate(query); + + assertThat(exchangeRate).isNull(); + } + + @Test + void shouldConvertUsingCurrencyConversion() { + CurrencyUnit dkp = currencyFactory.createDkpCurrency(); + ConversionQuery query = ConversionQueryBuilder.of() + .setBaseCurrency(Monetary.getCurrency("CHF")) + .setTermCurrency(dkp) + .build(); + CurrencyConversion conversion = exchangeRateProvider.getCurrencyConversion(query); + MonetaryAmount chfAmount = Money.of(100, "CHF"); + + MonetaryAmount convertedAmount = chfAmount.with(conversion); + + assertThat(convertedAmount.getCurrency().getCurrencyCode()).isEqualTo("DKP"); + assertThat(convertedAmount.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("10.00"); + } +} From 1c711a077ede65da110d01ff8445db973dca0c38 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Mon, 20 Apr 2026 10:31:00 +0200 Subject: [PATCH 8/8] 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; } }