Moneta is a library designed to support monetary calculations in a secure manner.
The following simple snippet demonstrates how Moneta can help you writing safe monetary code also in very simple scenarios.
public static void BadCode()
{
using var moneta = new MonetaContext();
var unitPrice = moneta.Dollar(1.12m);
var quantity = 12.43424m;
var finalPrice = unitPrice * quantity;
Console.WriteLine($"Unit price: {unitPrice}");
Console.WriteLine($"Quantity: {quantity}");
Console.WriteLine($"Final price: {finalPrice}");
} // an exception will be thrown because rounding errors were unnoticed
public static void GoodCode()
{
using var moneta = new MonetaContext();
var unitPrice = moneta.Dollar(1.12m);
var quantity = 12.43424m;
var finalPrice = unitPrice * quantity;
Console.WriteLine($"Unit price: {unitPrice}");
Console.WriteLine($"Quantity: {quantity}");
Console.WriteLine($"Final price: {finalPrice}");
if (moneta.HasRoundingErrors)
{
// Handle rounding errors as you prefer
moneta.ClearRoundingErrors();
}
}
- Moneta Context
- The Principle of Monetary Value Conservation
- Money
- Supported Operations
- Currency
- Rounding Error Detection
- Safe and Unsafe Operations
- Currency System
- Currency Providers
- Exchange Rate Conversion TODO
- Exchange Rate Providers TODO
- Samples TODO
- Dependency Injection TODO
A MonetaContext
sets the boundaries for safe and coherent monetary operations, ensuring that no rounding errors go unnoticed.
A MonetaContext
defines the following:
- The default
ICurrency
for creating newMoney
instances. For example, if you exclusively deal with EUR, you can set EUR as the default currency, making the creation of newMoney
instances more straightforward. - The
ICurrencyProvider
used to resolve currencies by code, enabling you to resolve currencies from a database or a web service. - The default
RoundingMode
for monetary operations. - The decimal precision used to detect rounding errors (see
RoundingErrorDecimals
). By default, all internal operations are rounded to 8 decimal places, but you can change this value up to 28 decimal places. - A log of operations that have resulted in unnoticed rounding errors (according to the
RoundingErrorDecimals
value).
In Moneta holds the «Monetary Value Conservation Principle»: no monetary value is created or lost unnoticed during the lifetime of a MonetaContext
.
Moneta will provide means to keep track of any monetary value created or lost during the lifetime of a MonetaContext
and make it available to the user who can decide how to handle it.
There are basically two main causes of monetary value creation or loss:
- Split or RoundOff Operations: which can produce an unallocated part, both positive or negative depending by the
RoundingMode
used. - Floating Point Operations with floating point numbers more precise than the
Currency
of theMoney
involved in the operation.
A rounding error occurs when an operation cannot be performed without losing precision.
These errors are influenced not only by the values involved in the operation but also by the RoundingMode
and
the MonetaContext.RoundingErrorDecimals
that is used by the MonetaContext
to detect rounding errors.
Important
MonetaContext
will prevent you to create any Money
that uses a Currency
with a number of DecimalPlaces
greather than the RoundingErrorDecimals
value.
Note
Using a RoundingErrorDecimals
equals to the Currency
with the highest number of DecimalPlaces
involved in your calculus will prevent any rounding error to get noticed.
Every result of an operation involving a floating point operand is rounded to the MonetaContext.RoundingErrorDecimals
before the monetary value is computed.
Thi value is then rounded accordingly to the decimals required by the Currency
of the Money
involved.
The difference between the two values is the rounding error.
Every safe operation will return a decimal
error value or, in the case of the Split and RoundOff, a Money
value representing the amount of value unallocated.
Unsafe operations, instead, will keep track of the operations and the errors occurred.
Schematically, let's assume that:
•
is the operation performedM
is theMoney
involved in the operationV
is the value (decimal
,double
orfloat
) involved in the operationR
is theMoney
returned by the operationE
is theerror
returned by the operation
then the following equation holds for the algebraic operations:
M • V = R + E
More precisely, if we indicate with
Similarly, in case of Split
, if we indicate with
Note
Note
The unallocated part Split
operation can be positive or negative depending by the rounding algorithm.
Important
error
is always a decimal
value with MonetaContext.RoundingErrorDecimals
decimals at most.
For example, if you perform the operation 1.00 EUR + 0.1234
with rounding mode ToZero
or ToEven
you will get 1.12 EUR
with a rounding error of 0.0034
,
which is the amount of monetary value lost during the operation.
But if you perform the same operation with rounding mode ToPositiveInfinity
you will get 1.13 EUR
with a rounding error of -0.0066
,
which is the amount of monetary value created during the operation.
It's up to you to decide which treatment to apply to the rounding errors depending by your domain requirements.
Moneta main design goal is to support safe monetary calculations through a fluent algebraic API.
For every supported operation, Moneta provides two set of overloads:
- safe overloads that returns the rounding error or, in the case of the
Spilt
operations, the unallocated part - unsafe overloads that doesn't return the created or lost monetary value and relies on the
MonetaContext
to keep track of it.
The error
or unallocated
part returned by the safe overloads represents a quantity of the monetary value that has been created or lost during the operation.
Money
is Moneta's type for representing monetary values. It consists of a decimal
value associated with a Currency
.
The supported operations are:
Split
: will split aMoney
into a list ofMoney
instances according to the specifiedRoundingMode
and number of parts or weights.Apply
: will apply a function to theMoney
value and return a newMoney
instance. There are many variants that accept different kind of functions suitable to transform theMoney
amount and/or theCurrency
.Add
,Subtract
,Multiply
,Divide
: will perform the corresponding operation between twoMoney
instances or aMoney
instance and a number, returning a newMoney
instance with the sameCurrency
.Negate
: will negate theMoney
value and return a newMoney
instance with the sameCurrency
.CompareTo
: will compare theMoney
value with anotherMoney
instance or a number. If theMoney
instances have differentCurrencies
, and theMonetaContext
has anIExchangeRateProvider
, theMoney
instances will be converted to the sameCurrency
before the comparison, otherwise an exception will be thrown.
And of course you can also use basic operators such as +
, -
, *
, /
, ==
, !=
, >
, >=
, <
, <=
. All the binary operators between a Money
instance and a number are unsafe operations (see Safe and Unsafe Operations).
In Moneta, a Currency is any instance of Currency<TSelf>
. There are no strict constraints on what qualifies as a valid currency; you are free to introduce your own currency as long as it fits your domain. The only requirement is that currencies with the same Code
are treated as equivalent.
An important characteristic of a currency is the number of decimal places it supports. When you perform a monetary operation, the result is rounded based on the currency's decimal precision, potentially resulting in rounding errors.
Rounding errors in split operations occur when you attempt to divide a Money
into parts that cannot be divided fairly according to the RoundingMode
used. For example, if you try to divide 1.00 EUR into 3 parts, the result will be 0.33 EUR with a rounding error of 0.01 EUR. Similar situations arise when you perform a weighted split operation using floating point weights.
Rounding errors in algebraic operations occur when you perform an operation between a Money
and a floating point number with a decimal part that exceeds the decimal places supported by the target Currency
. For instance, if you try to add 1.12345678 EUR to 1.12 EUR, the result will be 2.24 EUR with a rounding error of 0.00345678 EUR.
The Moneta core is equipped with common currencies and provides extension methods to the MonetaContext
. These methods aim to simplify the most common use cases, eliminating the need to inject an ICurrencyProvider
.
For example, if you operate with standard EUR and USD you can write something like this:
using var moneta = new MonetaContext();
var bucks = moneta.Dollars(100);
var euros = moneta.Euros(100);
var pounds = moneta.PoundingSterling(100);
var yens = moneta.Yen(100);
var yuans = moneta.Yuan(100);
Moneta's API offers two types of operations: safe and unsafe operations.
Safe operations are operations that return the error
of the operation to the caller and relieve the context from tracking rounding errors. The caller is responsible for handling the error in line with domain requirements.
In contrast, unsafe operations do not return the error
of the operation, and the context is responsible for keeping track of rounding errors. The caller must check the context for any rounding errors and address them accordingly.
In particular:
- All binary operations between a
Money
value and a floating point number are unsafe operations. - All
Money.Map
operations are unsafe operations.
In the following table there'is a recap of all the supported operations and their safety. An operation is considered safe if it can generate a rounding error and return it to the caller or it cannot generate a rounding error in any case.
For example, the Split
operation provides different overload methods, both safe and unsafe.
Operation | Safe | Unsafe | Notes |
---|---|---|---|
MonetaContext.Create |
Yes | Yes | Allocate a new Money . |
Split |
Yes | Yes | Split the value of a Money into a list of Money instances. There are two overloads: one that takes the number of parts and one that takes a list of weights. Split methods, instead of the rounding error, will return the more significative unallocated part as Money . |
RoundOff |
Yes | Yes | Round off the value of a Money to the monetary unit chosen. |
Apply |
Yes | Yes | Apply a function to the Money |
Add |
Yes | Yes | Adds a numeric value or a compatible Money |
Subtract |
Yes | Yes | Subtracts a numeric value or a compatible Money |
Multiply |
Yes | Yes | Multiplies for a numeric value |
Divide |
Yes | Yes | Divides by a numeric value or another Money |
Negate |
Yes | No | Negates the Money amount |
+, -, *, / with floating point numbers | No | Yes | The arithmetic operators. Binary operators between a Money value and a floating point number are unsafe operations. |
+, - with Money values with the same Currency or integral numbers or between any kind of UndefinedCurrency |
Yes | No | Binary operators between Money values with the same Currency or integral numbers are safe operations |
+, - with Money values with UndefinedCurrency with different DecimalPlaces |
No | Yes | Binary operators between Money values with UndefinedCurrency a number of DecimalPlaces greather than the value with a defined currency are unsafe. |
The Undefined Currency is a special currency identified by the ISO 4217 code XXX
. It represents a Money
without a currency. You can create multiple variants of UndefinedCurrency
with different DecimalPlaces
.
Two Currencies
are considered compatible if they share the same Code
.
Binary operations between two Money
instances are allowed only if their Currencies
are compatible.
Moneta offers numerous extension points for customizing the library's behavior. One of the most important is the ICurrencyProvider
, which enables you to seamlessly integrate your currency system. For example, you can implement an ICurrencyProvider
to resolve currencies from a database or a web service.
Moneta provides the following providers:
NullCurrencyProvider
, which doesn't resolve any currency.IsoCurrencyProvider
, which resolves currencies from the ISO 4217 standard. This provider can be customized to resolve a subset of the standard.ChainOfCurrencyProvider
, which resolves currencies from a list of other providers, suitable for caching and fallback scenarios.
TBD (To Be Determined)
TBD (To Be Determined)
These samples are available in the Moneta.Samples project.
using (var context = new MonetaContex(options))
{
var money = context.CreateMoney(1.00M);
money += 11;
money /= 2;
Console.WriteLine($"The final amount is {money}");
} // OK!
using (var context = new MonetaContex(options)))
{
var money = context.CreateMoney(1.00M);
money += 11;
money /= 2;
money += 1.2321; // Unhandled Rounding error
if (context.HasRoundingErrors)
{
Console.WriteLine(" > Rounding errors detected");
foreach (var error in context.RoundingErrors)
{
// TODO: Handle rounding errors
Console.WriteLine($" Error: {error}");
}
context.ClearRoundingErrors();
}
Console.WriteLine($"The final amount is {money}");
} // OK!
using (var context = new MonetaContex(options))
{
var money = context.CreateMoney(1.00M);
money += 11;
money /= 2;
money += 1.2321; // Unhandled Rounding error
Console.WriteLine($"The final amount is {money}");
} // KO! Exception thrown
using (var context = new MonetaContex(options))
{
var money = context.CreateMoney(11.11);
var weights = Enumerable.Repeat(0.333333, 3);
var split = money.Split(weights, out var unallocated);
Console.WriteLine($"The original amount is {money}");
Console.WriteLine($"The allocated amounts are: {string.Join(", ", split)}");
Console.WriteLine($"The unallocated amount is {unallocated}");
} // OK!
will produce the following output:
The original amount is EUR 11.11
The allocated amounts are: EUR 3.70, EUR 3.70, EUR 3.70
The unallocated amount is EUR 0.01
using (var context = new MonetaContex(options))
{
Console.WriteLine("\nSample 5: Rounding the final amount to the nearest 0.05 EUR (Cash rounding)");
var amounts = Enumerable.Repeat(context.CreateMoney(3.37), 17);
var total = amounts.Aggregate((x, y) => x + y); // sum up all the amounts
var cashUnit = context.CreateMoney(0.05); // define the cash unit
// round off the total to the highest multiple of the cash unit that is less than or equal to the total
// a kindness to our customers that always save some pennies :)
var cashTotal = total.RoundOff(cashUnit, MidpointRounding.ToZero, out var unallocated);
Console.WriteLine($"The original total amount is {total}");
Console.WriteLine($"The cash total amount is {cashTotal}");
Console.WriteLine($"The discounted amount is {unallocated}");
} // OK!
using (var context = new MonetaContex(options))
{
Console.WriteLine("\nSample 6: Calculating the P/E Ratio");
var price = context.CreateMoney(100);
var earnings = context.CreateMoney(10);
var peRatio = price / earnings;
Console.WriteLine($"The P/E Ratio is {peRatio}");
} // OK!