diff --git a/Funcky.Money.SourceGenerator/Iso4217RecordGenerator.cs b/Funcky.Money.SourceGenerator/Iso4217RecordGenerator.cs index cdc10ce..d0f0041 100644 --- a/Funcky.Money.SourceGenerator/Iso4217RecordGenerator.cs +++ b/Funcky.Money.SourceGenerator/Iso4217RecordGenerator.cs @@ -158,18 +158,15 @@ private static Option AlphabeticCurrencyName(XmlNode node) private static Option NumericCurrencyCode(XmlNode node) => GetInnerText(node, NumericCurrencyCodeNode) - .AndThen(ToInt); + .SelectMany(ParseExtensions.ParseInt32OrNone); private static int MinorUnit(XmlNode node) => GetInnerText(node, MinorUnitNode) - .AndThen(ToInt) + .SelectMany(ParseExtensions.ParseInt32OrNone) .GetOrElse(0); private static Option GetInnerText(XmlNode node, string nodeName) => Option .FromNullable(node.SelectSingleNode(nodeName)) .AndThen(n => n.InnerText); - - private static Option ToInt(string s) - => s.ParseInt32OrNone(); } diff --git a/Funcky.Money.Test/MoneyTest.cs b/Funcky.Money.Test/MoneyTest.cs index 0baad07..ad17061 100644 --- a/Funcky.Money.Test/MoneyTest.cs +++ b/Funcky.Money.Test/MoneyTest.cs @@ -1,10 +1,6 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; using FsCheck; using FsCheck.Xunit; -using Funcky.Extensions; using Xunit; namespace Funcky.Test; @@ -38,7 +34,7 @@ public Property TheSumOfTwoMoneysIsCommutative(Money money1, Money money2) } [Fact] - public void WeCanBuildTheSumOfTwoMoneysWithDifferentCurrenciesButOnEvaluationYouNeedAnEvaluationContext() + public void WeCanBuildTheSumOfTwoMoneysWithDifferentCurrenciesButOnEvaluationYouNeedAnEvaluationContextWithDefinedExchangeRates() { var fiveFrancs = new Money(5, Currency.CHF); var tenDollars = new Money(10, Currency.USD); @@ -46,6 +42,7 @@ public void WeCanBuildTheSumOfTwoMoneysWithDifferentCurrenciesButOnEvaluationYou var sum = fiveFrancs.Add(tenDollars); Assert.Throws(() => sum.Evaluate()); + Assert.Throws(() => sum.Evaluate(SwissRounding)); } [Property] @@ -302,27 +299,15 @@ public void WeCanDelegateTheExchangeRatesToABank() var sum = (fiveFrancs + tenDollars + fiveEuros) * 1.5m; - var context = MoneyEvaluationContext - .Builder - .Default - .WithTargetCurrency(Currency.CHF) - .WithBank(OneToOneBank.Instance) - .Build(); - - Assert.Equal(30m, sum.Evaluate(context).Amount); + Assert.Equal(30m, sum.Evaluate(OneToOneContext(Currency.CHF)).Amount); } [Fact] - public void EvaluationOnZeroMoniesWorks() + public void EvaluationOnZeroMoneysWorks() { var sum = (Money.Zero + Money.Zero) * 1.5m; - var context = MoneyEvaluationContext - .Builder - .Default - .WithTargetCurrency(Currency.JPY) - .WithBank(OneToOneBank.Instance) - .Build(); + var context = OneToOneContext(Currency.JPY); Assert.True(Money.Zero.Evaluate(context).IsZero); Assert.True(sum.Evaluate(context).IsZero); @@ -472,6 +457,32 @@ public void RoundingStrategiesMustBeInitializedWithAValidPrecision() Assert.Throws(() => _ = RoundingStrategy.RoundWithAwayFromZero(0.0m)); } + [Fact] + public void WeCanCalculateADimensionlessFactorByDividingAMoneyByAnother() + { + Assert.Equal(2.5m, Money.CHF(5) / Money.CHF(2)); + Assert.Equal(0.75m, Money.USD(3).Divide(Money.USD(4))); + } + + [Fact] + public void DividingTwoMoneysOnlyWorksIfTheyAreOfTheSameCurrency() + { + Assert.ThrowsAny(() => Money.CHF(5) / Money.USD(2)); + } + + [Fact] + public void DividingTwoMoneysWithDifferentCurrenciesNeedAnEvaluationContext() + { + Assert.Equal(0.8m, Money.CHF(4).Divide(Money.USD(5), OneToOneContext(Currency.USD))); + } + + [Fact] + public void DividingTwoMoneysOnlyWorksIfTheDivisorIsNonZero() + { + Assert.Throws(() => Money.CHF(5) / Money.Zero); + Assert.Throws(() => Money.USD(3).Divide(Money.USD(0))); + } + private static List Distributed(SwissMoney someMoney, int numberOfParts) => someMoney .Get @@ -500,4 +511,12 @@ private static IMoneyExpression ComplexExpression() return v3.Add(v2.Multiply(1.5m).Add(v1)).Add(v2.Multiply(2).Add(v1)) .Add(v3.Add(v2.Divide(2).Add(v1).Subtract(v2)).Add(v2.Add(v1))); } + + private static MoneyEvaluationContext OneToOneContext(Currency targetCurrency) + => MoneyEvaluationContext + .Builder + .Default + .WithTargetCurrency(targetCurrency) + .WithBank(OneToOneBank.Instance) + .Build(); } diff --git a/Funcky.Money.Test/TemporaryCultureSwitch.cs b/Funcky.Money.Test/TemporaryCultureSwitch.cs index 5f83c5c..e922f8c 100644 --- a/Funcky.Money.Test/TemporaryCultureSwitch.cs +++ b/Funcky.Money.Test/TemporaryCultureSwitch.cs @@ -1,4 +1,3 @@ -using System; using System.Globalization; namespace Funcky.Test; diff --git a/Funcky.Money/Bank/DefaultBank.cs b/Funcky.Money/Bank/DefaultBank.cs index 2fec233..d9f3a17 100644 --- a/Funcky.Money/Bank/DefaultBank.cs +++ b/Funcky.Money/Bank/DefaultBank.cs @@ -22,7 +22,7 @@ private DefaultBank() public decimal ExchangeRate(Currency source, Currency target) => ExchangeRates .GetValueOrNone(key: (source, target)) - .GetOrElse(() => throw new NotSupportedException($"No exchange rate for {source} => {target}")); + .GetOrElse(() => throw new MissingExchangeRateException($"No exchange rate for {source.AlphabeticCurrencyCode} => {target.AlphabeticCurrencyCode}.")); internal DefaultBank AddExchangeRate(Currency source, Currency target, decimal sellRate) => new(ExchangeRates.Add((source, target), sellRate)); diff --git a/Funcky.Money/Exceptions/MissingExchangeRateException.cs b/Funcky.Money/Exceptions/MissingExchangeRateException.cs new file mode 100644 index 0000000..1a1a2ad --- /dev/null +++ b/Funcky.Money/Exceptions/MissingExchangeRateException.cs @@ -0,0 +1,26 @@ +using System.Runtime.Serialization; + +namespace Funcky; + +public class MissingExchangeRateException : Exception +{ + public MissingExchangeRateException() + : base("If you calculate with more than one currency, you have to define the exchange rate and target in the evaluation context.") + { + } + + public MissingExchangeRateException(string? message) + : base(message) + { + } + + public MissingExchangeRateException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + protected MissingExchangeRateException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } +} diff --git a/Funcky.Money/ExpressionNodes/IMoneyExpression.cs b/Funcky.Money/ExpressionNodes/IMoneyExpression.cs index 1232fa9..e64a692 100644 --- a/Funcky.Money/ExpressionNodes/IMoneyExpression.cs +++ b/Funcky.Money/ExpressionNodes/IMoneyExpression.cs @@ -12,6 +12,9 @@ public interface IMoneyExpression public static IMoneyExpression operator /(IMoneyExpression dividend, decimal divisor) => dividend.Divide(divisor); + public static decimal operator /(IMoneyExpression dividend, IMoneyExpression divisor) + => dividend.Divide(divisor); + public static IMoneyExpression operator +(IMoneyExpression augend, IMoneyExpression addend) => augend.Add(addend); diff --git a/Funcky.Money/Extensions/MoneyDivisionExtension.cs b/Funcky.Money/Extensions/MoneyDivisionExtension.cs index 70ab354..add16a7 100644 --- a/Funcky.Money/Extensions/MoneyDivisionExtension.cs +++ b/Funcky.Money/Extensions/MoneyDivisionExtension.cs @@ -1,7 +1,19 @@ +using Funcky.Monads; + namespace Funcky; public static class MoneyDivisionExtension { public static IMoneyExpression Divide(this IMoneyExpression dividend, decimal divisor) => new MoneyProduct(dividend, 1.0m / divisor); + + public static decimal Divide(this IMoneyExpression dividend, IMoneyExpression divisor, Option context = default) + => Divide( + dividend.Evaluate(context), + divisor.Evaluate(context)); + + private static decimal Divide(Money dividend, Money divisor) + => dividend.Currency == divisor.Currency + ? dividend.Amount / divisor.Amount + : throw new MissingExchangeRateException(); } diff --git a/Funcky.Money/Funcky.Money.csproj b/Funcky.Money/Funcky.Money.csproj index 3a7ef85..c71a8a9 100644 --- a/Funcky.Money/Funcky.Money.csproj +++ b/Funcky.Money/Funcky.Money.csproj @@ -6,7 +6,7 @@ Funcky.Money is based on Kent Beck's TDD exercise but with more features. Functional Money true - 1.1.0 + 1.2.0 diff --git a/Funcky.Money/Money.cs b/Funcky.Money/Money.cs index 634bb8a..ede72ff 100644 --- a/Funcky.Money/Money.cs +++ b/Funcky.Money/Money.cs @@ -60,6 +60,9 @@ public bool IsZero public static IMoneyExpression operator /(Money dividend, decimal divisor) => dividend.Divide(divisor); + public static decimal operator /(Money dividend, IMoneyExpression divisor) + => dividend.Divide(divisor); + private static Currency SelectCurrency(Option currency) => currency.GetOrElse(CurrencyCulture.CurrentCurrency); diff --git a/Funcky.Money/MoneyEvaluationContext.cs b/Funcky.Money/MoneyEvaluationContext.cs index cb9ffeb..1707c17 100644 --- a/Funcky.Money/MoneyEvaluationContext.cs +++ b/Funcky.Money/MoneyEvaluationContext.cs @@ -1,4 +1,5 @@ using Funcky.Monads; +using static Funcky.Functional; namespace Funcky; @@ -8,8 +9,7 @@ private MoneyEvaluationContext(Currency targetCurrency, Option distribu { TargetCurrency = targetCurrency; DistributionUnit = distributionUnit; - RoundingStrategy = roundingStrategy - .GetOrElse(Funcky.RoundingStrategy.Default(distributionUnit.GetOrElse(Power.OfATenth(TargetCurrency.MinorUnitDigits)))); + RoundingStrategy = roundingStrategy.GetOrElse(Funcky.RoundingStrategy.Default(distributionUnit.GetOrElse(Power.OfATenth(TargetCurrency.MinorUnitDigits)))); Bank = bank; } @@ -60,14 +60,9 @@ private Builder(Option currency, Option distributionUnit, Opt } public MoneyEvaluationContext Build() - { - if (CompatibleRounding().Match(none: false, some: Negate)) - { - throw new IncompatibleRoundingException($"The roundingStrategy {_roundingStrategy} is incompatible with the smallest possible distribution unit {_distributionUnit}."); - } - - return CreateContext(); - } + => CompatibleRounding().Match(none: false, some: Not(Identity)) + ? throw new IncompatibleRoundingException($"The rounding strategy {_roundingStrategy} is incompatible with the smallest possible distribution unit {_distributionUnit}.") + : CreateContext(); public Builder WithTargetCurrency(Currency currency) => With(targetCurrency: currency); @@ -104,13 +99,9 @@ from unit in _distributionUnit private MoneyEvaluationContext CreateContext() => new( - _targetCurrency.GetOrElse(() - => throw new InvalidMoneyEvaluationContextBuilderException("Money evaluation context has no target currency set.")), + _targetCurrency.GetOrElse(() => throw new InvalidMoneyEvaluationContextBuilderException("Money evaluation context has no target currency set.")), _distributionUnit, _roundingStrategy, _bank); - - private bool Negate(bool c) - => !c; } } diff --git a/README.md b/README.md index 414d58a..933030b 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ These is the evolving list of TDD requirements which led to the implementation. * [x] Money distribution has a precision member, use that instead of the contrived Precision on Rounding. * [x] Add unary and binary minus and the division operator. * [x] The context has a smallest distribution unit. +* [x] A dimensionless factor can be calculated by dividing two money objects. ### Decisions @@ -119,6 +120,7 @@ These is the evolving list of TDD requirements which led to the implementation. * We keep Add, Multiply,etc because no all supported frameworks allow default implementations on the interface. * We prepare a distribution strategy but do not make it chosable at this point. * We support the following operators: unary + and -, and the binary operators ==, !=, +, -, * and /. +* You can divide two different currencies only with the `Divide(IMoneyExpression, IMoneyExpression, Option)` method where you have to give a `MoneyEvaluationContext` with the necessary exchange rates. ### Open Decisions