Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement divide money expressions #21

Merged
merged 16 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
{
Mafii marked this conversation as resolved.
Show resolved Hide resolved
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))
Mafii marked this conversation as resolved.
Show resolved Hide resolved
? 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.")),
Mafii marked this conversation as resolved.
Show resolved Hide resolved
_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
Loading