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