Skip to content

Commit

Permalink
Merge pull request #21 from polyadic/implement-divide-money-expressions
Browse files Browse the repository at this point in the history
Implement divide money expressions
  • Loading branch information
FreeApophis authored Aug 21, 2023
2 parents fd79709 + 88d08a3 commit 456033c
Show file tree
Hide file tree
Showing 11 changed files with 95 additions and 43 deletions.
7 changes: 2 additions & 5 deletions Funcky.Money.SourceGenerator/Iso4217RecordGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,18 +158,15 @@ private static Option<string> AlphabeticCurrencyName(XmlNode node)

private static Option<int> 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<string> GetInnerText(XmlNode node, string nodeName)
=> Option
.FromNullable(node.SelectSingleNode(nodeName))
.AndThen(n => n.InnerText);

private static Option<int> ToInt(string s)
=> s.ParseInt32OrNone();
}
59 changes: 39 additions & 20 deletions Funcky.Money.Test/MoneyTest.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,14 +34,15 @@ 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);

var sum = fiveFrancs.Add(tenDollars);

Assert.Throws<MissingEvaluationContextException>(() => sum.Evaluate());
Assert.Throws<MissingExchangeRateException>(() => sum.Evaluate(SwissRounding));
}

[Property]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -472,6 +457,32 @@ public void RoundingStrategiesMustBeInitializedWithAValidPrecision()
Assert.Throws<InvalidPrecisionException>(() => _ = 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<MissingExchangeRateException>(() => 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<DivideByZeroException>(() => Money.CHF(5) / Money.Zero);
Assert.Throws<DivideByZeroException>(() => Money.USD(3).Divide(Money.USD(0)));
}

private static List<decimal> Distributed(SwissMoney someMoney, int numberOfParts)
=> someMoney
.Get
Expand Down Expand Up @@ -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();
}
1 change: 0 additions & 1 deletion Funcky.Money.Test/TemporaryCultureSwitch.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System;
using System.Globalization;

namespace Funcky.Test;
Expand Down
2 changes: 1 addition & 1 deletion Funcky.Money/Bank/DefaultBank.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
26 changes: 26 additions & 0 deletions Funcky.Money/Exceptions/MissingExchangeRateException.cs
Original file line number Diff line number Diff line change
@@ -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)
{
}
}
3 changes: 3 additions & 0 deletions Funcky.Money/ExpressionNodes/IMoneyExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
12 changes: 12 additions & 0 deletions Funcky.Money/Extensions/MoneyDivisionExtension.cs
Original file line number Diff line number Diff line change
@@ -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<MoneyEvaluationContext> 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();
}
2 changes: 1 addition & 1 deletion Funcky.Money/Funcky.Money.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Description>Funcky.Money is based on Kent Beck's TDD exercise but with more features.</Description>
<PackageTags>Functional Money</PackageTags>
<IsPackable>true</IsPackable>
<Version>1.1.0</Version>
<Version>1.2.0</Version>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Test" />
Expand Down
3 changes: 3 additions & 0 deletions Funcky.Money/Money.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
=> currency.GetOrElse(CurrencyCulture.CurrentCurrency);

Expand Down
21 changes: 6 additions & 15 deletions Funcky.Money/MoneyEvaluationContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Funcky.Monads;
using static Funcky.Functional;

namespace Funcky;

Expand All @@ -8,8 +9,7 @@ private MoneyEvaluationContext(Currency targetCurrency, Option<decimal> 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;
}

Expand Down Expand Up @@ -60,14 +60,9 @@ private Builder(Option<Currency> currency, Option<decimal> 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<bool>(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);
Expand Down Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,15 @@ 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

* We construct `Money` objects only from `decimal` and `int`. The decision how to handle external rounding problems should be done before construction of a `Money` object.
* 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<MoneyEvaluationContext>)` method where you have to give a `MoneyEvaluationContext` with the necessary exchange rates.

### Open Decisions

Expand Down

0 comments on commit 456033c

Please sign in to comment.