Compare commits

..

5 commits

Author SHA1 Message Date
8e61f2d2e2
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 <marcus@fihlon.swiss>
2026-04-20 10:01:47 +02:00
59d2342104
docs(slides): add Otavio Santana as JSR-354 spec lead 2026-04-20 00:20:21 +02:00
50b6605285
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.
2026-04-20 00:19:32 +02:00
e8162f3f17
refactor(workshop): drop historical currency conversion exercises
Remove tasks depending on ECB-HIST as historical rate queries
are inconsistent and environment-dependent.
2026-04-20 00:18:14 +02:00
e7e8662414
refactor(workshop): remove redundant VAT rounding method
roundVatAmount duplicated default rounding logic and did not
demonstrate a distinct rounding use case.
2026-04-20 00:15:42 +02:00
17 changed files with 79 additions and 446 deletions

View file

@ -21,7 +21,7 @@ public class ArithmeticExercises {
* @return sum of both amounts in the same currency * @return sum of both amounts in the same currency
*/ */
public MonetaryAmount addAmounts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) { public MonetaryAmount addAmounts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) {
return firstAmount.add(secondAmount); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -34,7 +34,7 @@ public class ArithmeticExercises {
* @return difference after subtraction in the same currency * @return difference after subtraction in the same currency
*/ */
public MonetaryAmount subtractAmounts(MonetaryAmount minuend, MonetaryAmount subtrahend) { public MonetaryAmount subtractAmounts(MonetaryAmount minuend, MonetaryAmount subtrahend) {
return minuend.subtract(subtrahend); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -47,7 +47,7 @@ public class ArithmeticExercises {
* @return product amount in the same currency * @return product amount in the same currency
*/ */
public MonetaryAmount multiplyAmount(MonetaryAmount amount, int factor) { public MonetaryAmount multiplyAmount(MonetaryAmount amount, int factor) {
return amount.multiply(factor); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -60,6 +60,6 @@ public class ArithmeticExercises {
* @return {@code true} if first amount is greater, otherwise {@code false} * @return {@code true} if first amount is greater, otherwise {@code false}
*/ */
public boolean compareAmounts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) { public boolean compareAmounts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) {
return firstAmount.isGreaterThan(secondAmount); throw new UnsupportedOperationException("TODO");
} }
} }

View file

@ -1,9 +1,6 @@
package swiss.fihlon.workshop.money.part1; package swiss.fihlon.workshop.money.part1;
import java.util.Locale;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
import javax.money.format.MonetaryAmountFormat;
import javax.money.format.MonetaryFormats;
/** /**
* <p>Part 1 workshop exercises for formatting monetary amounts using JSR-354.</p> * <p>Part 1 workshop exercises for formatting monetary amounts using JSR-354.</p>
@ -24,7 +21,7 @@ public class FormattingExercises {
* @return formatted amount string for Swiss German locale * @return formatted amount string for Swiss German locale
*/ */
public String formatSwissGerman(MonetaryAmount amount) { public String formatSwissGerman(MonetaryAmount amount) {
return format(amount, "de-CH"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -36,7 +33,7 @@ public class FormattingExercises {
* @return formatted amount string for German locale * @return formatted amount string for German locale
*/ */
public String formatGerman(MonetaryAmount amount) { public String formatGerman(MonetaryAmount amount) {
return format(amount, "de-DE"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -48,20 +45,6 @@ public class FormattingExercises {
* @return formatted amount string for US locale * @return formatted amount string for US locale
*/ */
public String formatUs(MonetaryAmount amount) { public String formatUs(MonetaryAmount amount) {
return format(amount, "en-US"); throw new UnsupportedOperationException("TODO");
}
/**
* <p>Format the given amount using the specified locale.</p>
*
* <p>Use the provided language tag to obtain a locale-specific formatter.</p>
*
* @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);
} }
} }

View file

@ -1,13 +1,5 @@
package swiss.fihlon.workshop.money.part1; 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;
/** /**
* <p>Part 1 workshop exercises for combining parsing, arithmetic, rounding, and formatting with JSR-354.</p> * <p>Part 1 workshop exercises for combining parsing, arithmetic, rounding, and formatting with JSR-354.</p>
* *
@ -30,24 +22,6 @@ public class IntegrationExercises {
* @return formatted final price text * @return formatted final price text
*/ */
public String calculateFinalPrice(String priceText) { public String calculateFinalPrice(String priceText) {
// setup locale and formatter throw new UnsupportedOperationException("TODO");
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);
} }
} }

View file

@ -1,9 +1,6 @@
package swiss.fihlon.workshop.money.part1; package swiss.fihlon.workshop.money.part1;
import org.javamoney.moneta.Money;
import javax.money.CurrencyUnit; import javax.money.CurrencyUnit;
import javax.money.Monetary;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
/** /**
@ -22,7 +19,7 @@ public class MoneyExercises {
* @return Swiss franc currency unit ({@code CHF}) * @return Swiss franc currency unit ({@code CHF})
*/ */
public CurrencyUnit createSwissFranc() { public CurrencyUnit createSwissFranc() {
return Monetary.getCurrency("CHF"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -32,7 +29,7 @@ public class MoneyExercises {
* @return monetary amount {@code 19.95 CHF} * @return monetary amount {@code 19.95 CHF}
*/ */
public MonetaryAmount createSwissFrancAmount() { public MonetaryAmount createSwissFrancAmount() {
return Money.of(19.95, "CHF"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -42,7 +39,7 @@ public class MoneyExercises {
* @return monetary amount {@code 2500 JPY} * @return monetary amount {@code 2500 JPY}
*/ */
public MonetaryAmount createJapaneseYenAmount() { public MonetaryAmount createJapaneseYenAmount() {
return Money.of(2500, "JPY"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -52,6 +49,6 @@ public class MoneyExercises {
* @return monetary amount {@code 12.345 TND} * @return monetary amount {@code 12.345 TND}
*/ */
public MonetaryAmount createTunisianDinarAmount() { public MonetaryAmount createTunisianDinarAmount() {
return Money.of(12.345, "TND"); throw new UnsupportedOperationException("TODO");
} }
} }

View file

@ -1,9 +1,6 @@
package swiss.fihlon.workshop.money.part1; package swiss.fihlon.workshop.money.part1;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
import javax.money.format.MonetaryAmountFormat;
import javax.money.format.MonetaryFormats;
import javax.money.format.MonetaryParseException;
import java.util.Locale; import java.util.Locale;
/** /**
@ -27,7 +24,7 @@ public class ParsingExercises {
* @return parsed monetary amount * @return parsed monetary amount
*/ */
public MonetaryAmount parseSwissGerman(String text) { public MonetaryAmount parseSwissGerman(String text) {
return parse(text, "de-CH"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -39,7 +36,7 @@ public class ParsingExercises {
* @return parsed monetary amount * @return parsed monetary amount
*/ */
public MonetaryAmount parseGerman(String text) { public MonetaryAmount parseGerman(String text) {
return parse(text, "de-DE"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -51,22 +48,7 @@ public class ParsingExercises {
* @return parsed monetary amount * @return parsed monetary amount
*/ */
public MonetaryAmount parseUs(String text) { public MonetaryAmount parseUs(String text) {
return parse(text, "en-US"); throw new UnsupportedOperationException("TODO");
}
/**
* <p>Parse the given monetary amount text using the specified locale.</p>
*
* <p>Use the provided language tag to obtain a locale-specific parser.</p>
*
* @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);
} }
/** /**
@ -79,12 +61,6 @@ public class ParsingExercises {
* @return {@code true} if parsing succeeds, otherwise {@code false} * @return {@code true} if parsing succeeds, otherwise {@code false}
*/ */
public boolean parseInvalidInput(String text, Locale locale) { public boolean parseInvalidInput(String text, Locale locale) {
MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(locale); throw new UnsupportedOperationException("TODO");
try {
format.parse(text);
return true;
} catch (MonetaryParseException exception) {
return false;
}
} }
} }

View file

@ -1,8 +1,6 @@
package swiss.fihlon.workshop.money.part1; package swiss.fihlon.workshop.money.part1;
import javax.money.Monetary;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
import javax.money.MonetaryOperator;
/** /**
* <p>Part 1 workshop exercises for currency-aware rounding with JSR-354.</p> * <p>Part 1 workshop exercises for currency-aware rounding with JSR-354.</p>
@ -22,7 +20,7 @@ public class RoundingExercises {
* @return rounded amount * @return rounded amount
*/ */
public MonetaryAmount applyDefaultRounding(MonetaryAmount amount) { public MonetaryAmount applyDefaultRounding(MonetaryAmount amount) {
return amount.with(Monetary.getDefaultRounding()); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -35,7 +33,6 @@ public class RoundingExercises {
* @return divided and rounded amount * @return divided and rounded amount
*/ */
public MonetaryAmount divideAndRound(MonetaryAmount amount, int divisor) { public MonetaryAmount divideAndRound(MonetaryAmount amount, int divisor) {
return amount.divide(divisor) throw new UnsupportedOperationException("TODO");
.with(Monetary.getDefaultRounding());
} }
} }

View file

@ -2,7 +2,6 @@ package swiss.fihlon.workshop.money.part2;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
import javax.money.convert.CurrencyConversion; import javax.money.convert.CurrencyConversion;
import javax.money.convert.MonetaryConversions;
/** /**
* <p>Part 2 workshop exercises for currency conversion using JSR-354.</p> * <p>Part 2 workshop exercises for currency conversion using JSR-354.</p>
@ -21,7 +20,7 @@ public class CurrencyConversionExercises {
* @return currency conversion to EUR * @return currency conversion to EUR
*/ */
public CurrencyConversion getEurConversion() { public CurrencyConversion getEurConversion() {
return MonetaryConversions.getConversion("EUR", "ECB"); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -33,7 +32,6 @@ public class CurrencyConversionExercises {
* @return converted amount in EUR * @return converted amount in EUR
*/ */
public MonetaryAmount convertChfToEur(MonetaryAmount amount) { public MonetaryAmount convertChfToEur(MonetaryAmount amount) {
CurrencyConversion conversion = getEurConversion(); throw new UnsupportedOperationException("TODO");
return amount.with(conversion);
} }
} }

View file

@ -22,7 +22,7 @@ public class MonetaryContextExercises {
* @return monetary context of the amount * @return monetary context of the amount
*/ */
public MonetaryContext getMonetaryContext(MonetaryAmount amount) { public MonetaryContext getMonetaryContext(MonetaryAmount amount) {
return amount.getContext(); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -34,7 +34,7 @@ public class MonetaryContextExercises {
* @return maximum scale from the monetary context * @return maximum scale from the monetary context
*/ */
public int getMaxScale(MonetaryAmount amount) { public int getMaxScale(MonetaryAmount amount) {
return amount.getContext().getMaxScale(); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -47,7 +47,7 @@ public class MonetaryContextExercises {
* @return {@code true} if both contexts are equal, otherwise {@code false} * @return {@code true} if both contexts are equal, otherwise {@code false}
*/ */
public boolean compareContexts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) { public boolean compareContexts(MonetaryAmount firstAmount, MonetaryAmount secondAmount) {
return firstAmount.getContext().equals(secondAmount.getContext()); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -59,7 +59,6 @@ public class MonetaryContextExercises {
* @return formatted context description * @return formatted context description
*/ */
public String describeContext(MonetaryAmount amount) { public String describeContext(MonetaryAmount amount) {
MonetaryContext context = amount.getContext(); throw new UnsupportedOperationException("TODO");
return "precision=" + context.getPrecision() + ", maxScale=" + context.getMaxScale();
} }
} }

View file

@ -1,13 +1,14 @@
package swiss.fihlon.workshop.money.part2; package swiss.fihlon.workshop.money.part2;
import java.math.BigDecimal;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
import javax.money.MonetaryOperator; import javax.money.MonetaryOperator;
/** /**
* <p>Part 2 workshop exercises for encapsulating domain logic with MonetaryOperator.</p> * <p>Part 2 workshop exercises for encapsulating domain logic with MonetaryOperator.</p>
* *
* <p>The examples focus on applying reusable monetary operations such as discounts and VAT.</p> * <p>The exercises focus on applying reusable monetary operations such as discounts and VAT.</p>
*
* <p>The methods are intentionally incomplete and should be implemented by making tests pass.</p>
*/ */
public class MonetaryOperatorExercises { public class MonetaryOperatorExercises {
@ -20,8 +21,7 @@ public class MonetaryOperatorExercises {
* @return discounted amount * @return discounted amount
*/ */
public MonetaryAmount applyDiscount(MonetaryAmount amount) { public MonetaryAmount applyDiscount(MonetaryAmount amount) {
MonetaryOperator discount = createDiscountOperator(); throw new UnsupportedOperationException("TODO");
return amount.with(discount);
} }
/** /**
@ -33,8 +33,7 @@ public class MonetaryOperatorExercises {
* @return amount after VAT * @return amount after VAT
*/ */
public MonetaryAmount applyVat(MonetaryAmount amount) { public MonetaryAmount applyVat(MonetaryAmount amount) {
MonetaryOperator vat = createVatOperator(); throw new UnsupportedOperationException("TODO");
return amount.with(vat);
} }
/** /**
@ -46,18 +45,7 @@ public class MonetaryOperatorExercises {
* @return amount after discount and VAT * @return amount after discount and VAT
*/ */
public MonetaryAmount applyDiscountThenVat(MonetaryAmount amount) { public MonetaryAmount applyDiscountThenVat(MonetaryAmount amount) {
MonetaryOperator discount = createDiscountOperator(); throw new UnsupportedOperationException("TODO");
MonetaryOperator vat = createVatOperator();
return amount.with(discount).with(vat);
}
/**
* <p>Create a reusable 7.7% VAT monetary operator.</p>
*
* @return a reusable VAT operator
*/
public MonetaryOperator createVatOperator() {
return monetaryAmount -> monetaryAmount.multiply(new BigDecimal("1.077"));
} }
/** /**
@ -68,6 +56,6 @@ public class MonetaryOperatorExercises {
* @return reusable discount operator * @return reusable discount operator
*/ */
public MonetaryOperator createDiscountOperator() { public MonetaryOperator createDiscountOperator() {
return monetaryAmount -> monetaryAmount.multiply(new BigDecimal("0.90")); throw new UnsupportedOperationException("TODO");
} }
} }

View file

@ -7,7 +7,9 @@ import javax.money.MonetaryQuery;
/** /**
* <p>Part 2 workshop exercises for extracting information from monetary amounts with MonetaryQuery.</p> * <p>Part 2 workshop exercises for extracting information from monetary amounts with MonetaryQuery.</p>
* *
* <p>The examples focus on encapsulating reusable query logic for monetary values and currencies.</p> * <p>The exercises focus on encapsulating reusable query logic for monetary values and currencies.</p>
*
* <p>The methods are intentionally incomplete and should be implemented by making tests pass.</p>
*/ */
public class MonetaryQueryExercises { public class MonetaryQueryExercises {
@ -20,7 +22,7 @@ public class MonetaryQueryExercises {
* @return currency code * @return currency code
*/ */
public String queryCurrencyCode(MonetaryAmount amount) { public String queryCurrencyCode(MonetaryAmount amount) {
return amount.query(createCurrencyCodeQuery()); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -32,9 +34,7 @@ public class MonetaryQueryExercises {
* @return default fraction digits of the currency * @return default fraction digits of the currency
*/ */
public int queryDefaultFractionDigits(MonetaryAmount amount) { public int queryDefaultFractionDigits(MonetaryAmount amount) {
MonetaryQuery<Integer> query = throw new UnsupportedOperationException("TODO");
monetaryAmount -> monetaryAmount.getCurrency().getDefaultFractionDigits();
return amount.query(query);
} }
/** /**
@ -46,9 +46,7 @@ public class MonetaryQueryExercises {
* @return numeric value as BigDecimal * @return numeric value as BigDecimal
*/ */
public BigDecimal queryNumberAsBigDecimal(MonetaryAmount amount) { public BigDecimal queryNumberAsBigDecimal(MonetaryAmount amount) {
MonetaryQuery<BigDecimal> query = throw new UnsupportedOperationException("TODO");
monetaryAmount -> monetaryAmount.getNumber().numberValueExact(BigDecimal.class);
return amount.query(query);
} }
/** /**
@ -59,6 +57,6 @@ public class MonetaryQueryExercises {
* @return reusable query returning a currency code * @return reusable query returning a currency code
*/ */
public MonetaryQuery<String> createCurrencyCodeQuery() { public MonetaryQuery<String> createCurrencyCodeQuery() {
return monetaryAmount -> monetaryAmount.getCurrency().getCurrencyCode(); throw new UnsupportedOperationException("TODO");
} }
} }

View file

@ -1,15 +1,14 @@
package swiss.fihlon.workshop.money.part2; 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.MonetaryAmount;
import javax.money.MonetaryOperator; import javax.money.MonetaryOperator;
/** /**
* <p>Part 2 workshop examples for applying different rounding strategies with JSR-354.</p> * <p>Part 2 workshop exercises for applying different rounding strategies with JSR-354.</p>
* *
* <p>The examples focus on treating rounding as an explicit domain decision that depends on the use case.</p> * <p>The exercises focus on treating rounding as an explicit domain decision that depends on the use case.</p>
*
* <p>The methods are intentionally incomplete and should be implemented by making tests pass.</p>
*/ */
public class RoundingStrategyExercises { public class RoundingStrategyExercises {
@ -22,8 +21,7 @@ public class RoundingStrategyExercises {
* @return amount rounded with default currency rounding * @return amount rounded with default currency rounding
*/ */
public MonetaryAmount applyDefaultRounding(MonetaryAmount amount) { public MonetaryAmount applyDefaultRounding(MonetaryAmount amount) {
MonetaryOperator rounding = Monetary.getDefaultRounding(); throw new UnsupportedOperationException("TODO");
return amount.with(rounding);
} }
/** /**
@ -35,8 +33,7 @@ public class RoundingStrategyExercises {
* @return CHF amount rounded to the nearest 0.05 * @return CHF amount rounded to the nearest 0.05
*/ */
public MonetaryAmount applySwissCashRounding(MonetaryAmount amount) { public MonetaryAmount applySwissCashRounding(MonetaryAmount amount) {
MonetaryOperator swissCashRounding = createSwissCashRounding(); throw new UnsupportedOperationException("TODO");
return amount.with(swissCashRounding);
} }
/** /**
@ -47,19 +44,6 @@ public class RoundingStrategyExercises {
* @return reusable Swiss cash rounding operator * @return reusable Swiss cash rounding operator
*/ */
public MonetaryOperator createSwissCashRounding() { public MonetaryOperator createSwissCashRounding() {
return monetaryAmount -> { throw new UnsupportedOperationException("TODO");
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();
};
} }
} }

View file

@ -1,19 +1,14 @@
package swiss.fihlon.workshop.money.part3; package swiss.fihlon.workshop.money.part3;
import java.math.BigDecimal;
import java.util.Locale;
import javax.money.CurrencyUnit; import javax.money.CurrencyUnit;
import javax.money.Monetary;
import javax.money.MonetaryAmount; import javax.money.MonetaryAmount;
import javax.money.MonetaryOperator;
import javax.money.convert.MonetaryConversions;
import javax.money.format.MonetaryAmountFormat;
import javax.money.format.MonetaryFormats;
/** /**
* <p>Part 3 workshop examples for integrating monetary calculations into a small order workflow.</p> * <p>Part 3 workshop exercises for integrating monetary calculations into a small order workflow.</p>
* *
* <p>The examples focus on combining subtotal calculation, discount, VAT, rounding, and formatting.</p> * <p>The exercises focus on combining subtotal calculation, discount, VAT, rounding, and formatting.</p>
*
* <p>The methods are intentionally incomplete and should be implemented by making tests pass.</p>
*/ */
public class OrderExercises { public class OrderExercises {
@ -27,7 +22,7 @@ public class OrderExercises {
* @return subtotal amount * @return subtotal amount
*/ */
public MonetaryAmount calculateSubtotal(MonetaryAmount unitPrice, int quantity) { public MonetaryAmount calculateSubtotal(MonetaryAmount unitPrice, int quantity) {
return unitPrice.multiply(quantity); throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -39,8 +34,7 @@ public class OrderExercises {
* @return discounted amount * @return discounted amount
*/ */
public MonetaryAmount applyDiscount(MonetaryAmount subtotal) { public MonetaryAmount applyDiscount(MonetaryAmount subtotal) {
MonetaryOperator discount = amount -> amount.multiply(new BigDecimal("0.90")); throw new UnsupportedOperationException("TODO");
return subtotal.with(discount);
} }
/** /**
@ -52,9 +46,7 @@ public class OrderExercises {
* @return VAT-inclusive amount * @return VAT-inclusive amount
*/ */
public MonetaryAmount applyVat(MonetaryAmount discountedAmount) { public MonetaryAmount applyVat(MonetaryAmount discountedAmount) {
MonetaryAmount vatInclusiveAmount = discountedAmount.multiply(new BigDecimal("1.077")); throw new UnsupportedOperationException("TODO");
MonetaryOperator rounding = Monetary.getDefaultRounding();
return vatInclusiveAmount.with(rounding);
} }
/** /**
@ -67,9 +59,7 @@ public class OrderExercises {
* @return final total amount * @return final total amount
*/ */
public MonetaryAmount calculateTotal(MonetaryAmount unitPrice, int quantity) { public MonetaryAmount calculateTotal(MonetaryAmount unitPrice, int quantity) {
MonetaryAmount subtotal = calculateSubtotal(unitPrice, quantity); throw new UnsupportedOperationException("TODO");
MonetaryAmount discountedAmount = applyDiscount(subtotal);
return applyVat(discountedAmount);
} }
/** /**
@ -81,8 +71,7 @@ public class OrderExercises {
* @return formatted total string * @return formatted total string
*/ */
public String formatTotal(MonetaryAmount total) { public String formatTotal(MonetaryAmount total) {
MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(Locale.forLanguageTag("de-CH")); throw new UnsupportedOperationException("TODO");
return format.format(total);
} }
/** /**
@ -95,6 +84,6 @@ public class OrderExercises {
* @return amount converted to the target currency * @return amount converted to the target currency
*/ */
public MonetaryAmount convertToCurrency(MonetaryAmount amount, CurrencyUnit targetCurrency) { public MonetaryAmount convertToCurrency(MonetaryAmount amount, CurrencyUnit targetCurrency) {
return amount.with(MonetaryConversions.getConversion(targetCurrency, "ECB")); throw new UnsupportedOperationException("TODO");
} }
} }

View file

@ -1,30 +1,18 @@
package swiss.fihlon.workshop.money.part4; 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.ConversionQuery;
import javax.money.convert.ConversionContext;
import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.CurrencyConversion; import javax.money.convert.CurrencyConversion;
import javax.money.convert.ExchangeRate; import javax.money.convert.ExchangeRate;
import javax.money.convert.ExchangeRateProvider; import javax.money.convert.ExchangeRateProvider;
import javax.money.convert.ProviderContext; 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;
/** /**
* <p>Bonus exercise 1 for implementing a local CSV-based exchange rate provider with JSR-354.</p> * <p>Bonus exercise 1 for implementing a local CSV-based exchange rate provider with JSR-354.</p>
* *
* <p>The provider is intended for workshop usage and should read rates from the classpath resource * <p>The provider is intended for workshop usage and should read rates from the classpath resource
* {@code exchange-rates.csv}.</p> * {@code exchange-rates.csv}.</p>
*
* <p>The implementation is intentionally incomplete and should be finished by making tests pass.</p>
*/ */
public class CsvExchangeRateProvider implements ExchangeRateProvider { public class CsvExchangeRateProvider implements ExchangeRateProvider {
@ -32,10 +20,6 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
* <p>Name of the classpath CSV resource containing exchange rates.</p> * <p>Name of the classpath CSV resource containing exchange rates.</p>
*/ */
public static final String RESOURCE_NAME = "exchange-rates.csv"; 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<CurrencyPair, BigDecimal> ratesByPair;
/** /**
* <p>Create a new provider instance.</p> * <p>Create a new provider instance.</p>
@ -43,7 +27,6 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
* <p>The provider should load exchange rates from {@link #RESOURCE_NAME}.</p> * <p>The provider should load exchange rates from {@link #RESOURCE_NAME}.</p>
*/ */
public CsvExchangeRateProvider() { public CsvExchangeRateProvider() {
this.ratesByPair = loadRates();
} }
/** /**
@ -53,7 +36,7 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public ProviderContext getContext() { public ProviderContext getContext() {
return CONTEXT; throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -64,16 +47,7 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
CurrencyPair currencyPair = CurrencyPair.from(conversionQuery); throw new UnsupportedOperationException("TODO");
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();
} }
/** /**
@ -86,60 +60,6 @@ public class CsvExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) {
if (getExchangeRate(conversionQuery) == null) { throw new UnsupportedOperationException("TODO");
return null;
}
ConversionContext conversionContext = ConversionContextBuilder.create(getContext(), RateType.DEFERRED).build();
return new LazyBoundCurrencyConversion(conversionQuery, this, conversionContext);
}
/**
* <p>Load exchange rates from the classpath CSV resource into a lookup map.</p>
*
* <p>The CSV is expected to contain a header and records in the format
* {@code base,term,rate}.</p>
*
* @return map keyed by currency pair containing the configured exchange factor
*/
private static Map<CurrencyPair, BigDecimal> 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<CurrencyPair, BigDecimal> 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);
}
}
/**
* <p>Simple key for identifying an exchange rate by base and term currency code.</p>
*
* @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) {
/**
* <p>Create a currency-pair key from a conversion query.</p>
*
* @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());
}
} }
} }

View file

@ -1,27 +1,11 @@
package swiss.fihlon.workshop.money.part4; 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.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.ConversionQuery;
import javax.money.convert.ConversionContext;
import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.CurrencyConversion; import javax.money.convert.CurrencyConversion;
import javax.money.convert.ExchangeRate; import javax.money.convert.ExchangeRate;
import javax.money.convert.ExchangeRateProvider; import javax.money.convert.ExchangeRateProvider;
import javax.money.convert.ProviderContext; 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;
/** /**
* <p>Bonus exercise 2 for implementing a local CSV-based historical exchange rate provider with JSR-354.</p> * <p>Bonus exercise 2 for implementing a local CSV-based historical exchange rate provider with JSR-354.</p>
@ -30,6 +14,8 @@ import org.javamoney.moneta.spi.LazyBoundCurrencyConversion;
* {@code exchange-rates-hist.csv}.</p> * {@code exchange-rates-hist.csv}.</p>
* *
* <p>The lookup should use base currency, term currency, and a date passed in the query context.</p> * <p>The lookup should use base currency, term currency, and a date passed in the query context.</p>
*
* <p>The implementation is intentionally incomplete and should be finished by making tests pass.</p>
*/ */
public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider { public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
@ -42,10 +28,6 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
* <p>Query key type for historical lookups.</p> * <p>Query key type for historical lookups.</p>
*/ */
public static final Class<LocalDate> DATE_QUERY_KEY = LocalDate.class; public static final Class<LocalDate> DATE_QUERY_KEY = LocalDate.class;
private static final ProviderContext CONTEXT =
ProviderContextBuilder.of("csv-historical-exchange-rate-provider", RateType.HISTORIC).build();
private final Map<CurrencyPair, NavigableMap<LocalDate, BigDecimal>> ratesByPairAndDate;
/** /**
* <p>Create a new provider instance.</p> * <p>Create a new provider instance.</p>
@ -53,7 +35,6 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
* <p>The provider should load exchange rates from {@link #RESOURCE_NAME}.</p> * <p>The provider should load exchange rates from {@link #RESOURCE_NAME}.</p>
*/ */
public CsvHistoricalExchangeRateProvider() { public CsvHistoricalExchangeRateProvider() {
this.ratesByPairAndDate = loadRates();
} }
/** /**
@ -63,7 +44,7 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public ProviderContext getContext() { public ProviderContext getContext() {
return CONTEXT; throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -81,27 +62,7 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
LocalDate requestedDate = conversionQuery.get(DATE_QUERY_KEY); throw new UnsupportedOperationException("TODO");
if (requestedDate == null) {
return null;
}
CurrencyPair currencyPair = CurrencyPair.from(conversionQuery);
NavigableMap<LocalDate, BigDecimal> ratesByDate = ratesByPairAndDate.get(currencyPair);
if (ratesByDate == null) {
return null;
}
Map.Entry<LocalDate, BigDecimal> 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();
} }
/** /**
@ -117,61 +78,6 @@ public class CsvHistoricalExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) {
if (getExchangeRate(conversionQuery) == null) { throw new UnsupportedOperationException("TODO");
return null;
}
ConversionContext conversionContext = ConversionContextBuilder.create(getContext(), RateType.HISTORIC).build();
return new LazyBoundCurrencyConversion(conversionQuery, this, conversionContext);
}
/**
* <p>Load historical exchange rates from the classpath CSV resource into a date-indexed lookup map.</p>
*
* <p>The CSV is expected to contain a header and records in the format
* {@code date,base,term,rate}.</p>
*
* @return map keyed by currency pair with a navigable date-to-rate mapping
*/
private static Map<CurrencyPair, NavigableMap<LocalDate, BigDecimal>> 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<CurrencyPair, NavigableMap<LocalDate, BigDecimal>> 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);
}
}
/**
* <p>Simple key for identifying a historical exchange-rate series by base and term currency code.</p>
*
* @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) {
/**
* <p>Create a currency-pair key from a conversion query.</p>
*
* @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());
}
} }
} }

View file

@ -1,32 +1,20 @@
package swiss.fihlon.workshop.money.part4; package swiss.fihlon.workshop.money.part4;
import java.math.BigDecimal;
import javax.money.convert.ConversionQuery; import javax.money.convert.ConversionQuery;
import javax.money.convert.ConversionContext;
import javax.money.convert.ConversionContextBuilder;
import javax.money.convert.CurrencyConversion; import javax.money.convert.CurrencyConversion;
import javax.money.convert.ExchangeRate; import javax.money.convert.ExchangeRate;
import javax.money.convert.ExchangeRateProvider; import javax.money.convert.ExchangeRateProvider;
import javax.money.convert.ProviderContext; 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;
/** /**
* <p>Bonus exercise 3 for implementing local exchange rates between CHF and DukePoints.</p> * <p>Bonus exercise 3 for implementing local exchange rates between CHF and DukePoints.</p>
* *
* <p>The provider should support deterministic conversion for {@code CHF -> DKP} and {@code DKP -> CHF}.</p> * <p>The provider should support deterministic conversion for {@code CHF -> DKP} and {@code DKP -> CHF}.</p>
*
* <p>The implementation is intentionally incomplete and should be finished by making tests pass.</p>
*/ */
public class DukePointsExchangeRateProvider implements ExchangeRateProvider { 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";
/** /**
* <p>Create a new exchange rate provider for DukePoints.</p> * <p>Create a new exchange rate provider for DukePoints.</p>
*/ */
@ -40,7 +28,7 @@ public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public ProviderContext getContext() { public ProviderContext getContext() {
return CONTEXT; throw new UnsupportedOperationException("TODO");
} }
/** /**
@ -53,19 +41,7 @@ public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) { public ExchangeRate getExchangeRate(ConversionQuery conversionQuery) {
String baseCurrencyCode = conversionQuery.getBaseCurrency().getCurrencyCode(); throw new UnsupportedOperationException("TODO");
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();
} }
/** /**
@ -78,27 +54,6 @@ public class DukePointsExchangeRateProvider implements ExchangeRateProvider {
*/ */
@Override @Override
public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) { public CurrencyConversion getCurrencyConversion(ConversionQuery conversionQuery) {
if (getExchangeRate(conversionQuery) == null) { throw new UnsupportedOperationException("TODO");
return null;
}
ConversionContext conversionContext = ConversionContextBuilder.create(getContext(), RateType.HISTORIC).build();
return new LazyBoundCurrencyConversion(conversionQuery, this, conversionContext);
}
/**
* <p>Resolve the conversion factor for a supported DukePoints currency pair.</p>
*
* @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;
} }
} }

View file

@ -1,13 +1,13 @@
package swiss.fihlon.workshop.money.part2; 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.javamoney.moneta.Money;
import org.junit.jupiter.api.Test; 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 { class CurrencyConversionExercisesTest {
private final CurrencyConversionExercises exercises = new CurrencyConversionExercises(); private final CurrencyConversionExercises exercises = new CurrencyConversionExercises();
@ -29,25 +29,4 @@ class CurrencyConversionExercisesTest {
assertThat(result).isNotNull(); assertThat(result).isNotNull();
assertThat(result.getCurrency().getCurrencyCode()).isEqualTo("EUR"); 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");
}
} }

View file

@ -1,11 +1,12 @@
package swiss.fihlon.workshop.money.part2; 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.javamoney.moneta.Money;
import org.junit.jupiter.api.Test; 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; import static org.assertj.core.api.Assertions.assertThat;
class RoundingStrategyExercisesTest { class RoundingStrategyExercisesTest {
@ -23,17 +24,6 @@ class RoundingStrategyExercisesTest {
assertThat(result.getNumber().numberValueExact(BigDecimal.class)).isEqualByComparingTo("10.02"); 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 @Test
void shouldApplySwissCashRounding() { void shouldApplySwissCashRounding() {
MonetaryAmount firstAmount = Money.of(10.02, "CHF"); MonetaryAmount firstAmount = Money.of(10.02, "CHF");