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
ICurrencyfor creating newMoneyinstances. For example, if you exclusively deal with EUR, you can set EUR as the default currency, making the creation of newMoneyinstances more straightforward. - The
ICurrencyProviderused to resolve currencies by code, enabling you to resolve currencies from a database or a web service. - The default
RoundingModefor 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
RoundingErrorDecimalsvalue).
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
RoundingModeused. - Floating Point Operations with floating point numbers more precise than the
Currencyof theMoneyinvolved 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 performedMis theMoneyinvolved in the operationVis the value (decimal,doubleorfloat) involved in the operationRis theMoneyreturned by the operationEis theerrorreturned 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
Spiltoperations, the unallocated part - unsafe overloads that doesn't return the created or lost monetary value and relies on the
MonetaContextto 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 aMoneyinto a list ofMoneyinstances according to the specifiedRoundingModeand number of parts or weights.Apply: will apply a function to theMoneyvalue and return a newMoneyinstance. There are many variants that accept different kind of functions suitable to transform theMoneyamount and/or theCurrency.Add,Subtract,Multiply,Divide: will perform the corresponding operation between twoMoneyinstances or aMoneyinstance and a number, returning a newMoneyinstance with the sameCurrency.Negate: will negate theMoneyvalue and return a newMoneyinstance with the sameCurrency.CompareTo: will compare theMoneyvalue with anotherMoneyinstance or a number. If theMoneyinstances have differentCurrencies, and theMonetaContexthas anIExchangeRateProvider, theMoneyinstances will be converted to the sameCurrencybefore 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
Moneyvalue and a floating point number are unsafe operations. - All
Money.Mapoperations 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 thrownusing (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!will produce the following output:
Sample 5: Rounding the final amount to the nearest 0.05 EUR (Cash rounding)
The original total amount is 57.29
The cash total amount is 57.25
The discounted amount is 0.04
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!