Bodu.Financial - Getting started
Install
dotnet add package Bodu.Financial
Targets net8.0. References Bodu.Numerics (for the Fraction<BigInteger> precision escape hatch) and Bodu.Core (for shared argument validation).
Minimal samples
Typed monetary arithmetic (Money<TCurrency>)
using Bodu.Financial;
using Bodu.Financial.Currencies;
Money<USD> dinner = new Money<USD>(54.30m);
Money<USD> tip = dinner * 0.18m;
Money<USD> total = dinner + tip; // OK - same currency
Money<JPY> sushi = new Money<JPY>(2500m);
var oops = dinner + sushi; // Compile error - cannot add USD to JPY
Construction rounds to the currency's minor-unit precision using banker's rounding:
MinorUnits = 0-JPY,KRW,CLP,ISK,VND,XAF,XOF, …MinorUnits = 2-USD,EUR,GBP,AUD,CAD,CHF, and most others.MinorUnits = 3-BHD,IQD,JOD,KWD,LYD,OMR,TND.
Cross-currency conversion
Money<USD> usd = new Money<USD>(100m);
Money<JPY> jpy = usd.Convert<JPY>(155.5m); // 15,550 JPY (0 dp)
Money<EUR> eur = usd.Convert<EUR>(0.93m); // 93.00 EUR (2 dp)
No implicit conversion - the rate must be supplied. Rounding is applied to the destination currency's minor-unit precision.
Fair allocation
Money<USD>[] shares = new Money<USD>(0.10m).Allocate(3);
// [0.04, 0.03, 0.03] - sums to exactly 0.10
decimal[] ratios = { 1m, 1m, 2m };
Money<USD>[] split = new Money<USD>(100m).Allocate(ratios);
// [25.00, 25.00, 50.00]
The residual minor units are distributed one per share from the start of the array, so the sum equals the original exactly.
Exact intermediate arithmetic via Fraction<BigInteger>
using System.Numerics;
using Bodu.Financial;
using Bodu.Financial.Currencies;
using Bodu.Numerics;
Money<USD> principal = new Money<USD>(1000m);
Fraction<BigInteger> growth = Fraction<BigInteger>.One
+ Fraction<BigInteger>.Create(5, 1200); // 5% / 12 months
Fraction<BigInteger> exact = principal.ToFraction();
for (int i = 0; i < 360; i++) exact *= growth; // 30-year amortization, no drift
Money<USD> balance = Money<USD>.FromFraction(exact); // one rounding event
Single-step variant: principal.MultiplyExact(growth).
Deferred rounding with CalculatedMoney
When you only need to defer rounding across a chain of decimal steps -
not full rational exactness - Money<T>.ToCalculated() returns a
runtime-tagged CalculatedMoney that carries the
full decimal precision through arithmetic and rounds once, at the
settlement boundary:
CalculatedMoney running = new Money<USD>(100m).ToCalculated();
running = running * 1.05m / 3m; // no rounding yet
Money settled = running.RoundToMoney(); // single rounding event → Money
Integer minor-unit storage
Money<T>.ToMinorUnits() and FromMinorUnits(long) bridge to the
integer-cent representation ledgers and wire formats use:
long cents = new Money<USD>(19.99m).ToMinorUnits(); // 1999
Money<USD> back = Money<USD>.FromMinorUnits(1999); // USD 19.99
Runtime-tagged amounts (Money)
// `options` is a JsonSerializerOptions with the financial converters registered - see the JSON section below.
Money invoice = JsonSerializer.Deserialize<Money>(payload, options)!;
// invoice could be "USD 19.99", "EUR 19.99", or "JPY 200" - same code path.
Money total = invoice + new Money(5m, invoice.Code); // Code is the CurrencyCode enum
Money<USD> typed = invoice.As<USD>(); // throws if mismatch
bool ok = invoice.TryAs(out Money<USD> r); // safe variant
Mixed-currency portfolios (MoneyBag)
MoneyBag wallet = MoneyBag.Empty
.Add(new Money<USD>(100m))
.Add(new Money<EUR>(50m))
.Add(new Money<JPY>(10_000m));
wallet.GetBalance<USD>(); // Money<USD>? 100.00
wallet.GetBalance(CurrencyCode.EUR); // Money? - EUR 50.00
wallet.Count; // 3
Convert the bag to a single target currency via an IRateProvider:
var rates = new Dictionary<(string From, string To), decimal>
{
{ ("EUR", "USD"), 1.10m },
{ ("JPY", "USD"), 0.0067m },
};
FixedRateTable table = new(rates);
Money<USD> totalInUsd = wallet.ConvertTo<USD>(table);
// 100 + 50×1.10 + 10000×0.0067 = $222.00
Dated FX lookup with audit metadata
using Bodu.Financial.ExchangeRates;
using Bodu.Financial.Extensions; // IsExactDate / ResolvedDate extension members
IDatedRateProvider provider = …;
RateLookupResult lookup = provider.GetRate(
"USD", "EUR",
new DateOnly(2024, 6, 15),
RateLookupOptions.NearestWithin(7));
Console.WriteLine($"Used {lookup.Rate.Date} from {lookup.Rate.Provider}");
Console.WriteLine($"Offset: {lookup.OffsetDays} day(s), exact: {lookup.IsExactDate()}");
The lookup result is a readonly record struct carrying the resolved
Rate (an ExchangeRate), the RequestedDate, the
Resolution policy that fired, the absolute OffsetDays, and the
Provenance. IsExactDate, ResolvedDate, SignedOffsetDays,
IsPreviousDate, and IsFutureDate are extension members in
Bodu.Financial.Extensions rather than properties on the record.
Cash rounding
new Money<CHF>(12.34m).RoundToCash(); // CHF 12.35
new Money<NZD>(5.07m).RoundToCash(); // NZD 5.10
new Money<USD>(19.99m).RoundToCash(); // USD 19.99 - no-op, no cash increment
The currency's CashRoundingIncrement (e.g. 0.05m for CHF) drives the snap. Electronic transactions retain full minor-unit precision; use RoundToCash() only at the point a total becomes a cash payment.
JSON
JSON support ships in the companion Bodu.Financial.Serialization.Json package (dotnet add package Bodu.Financial.Serialization.Json); the core types carry no [JsonConverter] attribute, so register the converters before serializing. The default Strict policy emits the canonical object shape:
{ "amount": 19.99, "currency": "USD" }
using Bodu.Financial.Serialization.Json;
var options = new JsonSerializerOptions().AddFinancialJsonConverters();
For lenient parsing or the compact string form ("19.99 USD"), pass an explicit policy - for example options.AddFinancialJsonConverters(FinancialJsonPolicy.Compact).
A unit outside the shipped catalogue
The runtime Money is closed to the shipped CurrencyCode set. For a
generic amount in a unit outside ISO 4217, declare your own ICurrency
tag (its IsoCode must be three uppercase ASCII letters) and use
Money<TCurrency>; it carries its own precision and stays in the
generic world - it cannot bridge to the runtime Money.
public sealed class XPT : ICurrency // troy ounces of platinum, say
{
public static string IsoCode => "XPT";
public static int MinorUnits => 4;
private XPT() { }
}
Money<XPT> holding = new Money<XPT>(12.3456m);
Where to go next
- Bodu.Financial introduction - namespaces, headline types, scenarios.
- Working with
Money<TCurrency>- the full reference for typed money, including formatting/parsing, locale-aware output, cash rounding, historic-currency metadata,Moneyinterop, andMoneyBagportfolios. - Bodu.Numerics getting started - for the
Fraction<BigInteger>precision escape hatch used byMoney<T>.ToFraction(). - Bodu.Financial API reference - full type-by-type docs.
- Runnable samples - offline sample projects under
samples/Financial/you candotnet runand copy from.