Bodu.Financial
0.7.0
See the version list below for details.
dotnet add package Bodu.Financial --version 0.7.0
NuGet\Install-Package Bodu.Financial -Version 0.7.0
<PackageReference Include="Bodu.Financial" Version="0.7.0" />
<PackageVersion Include="Bodu.Financial" Version="0.7.0" />
<PackageReference Include="Bodu.Financial" />
paket add Bodu.Financial --version 0.7.0
#r "nuget: Bodu.Financial, 0.7.0"
#:package Bodu.Financial@0.7.0
#addin nuget:?package=Bodu.Financial&version=0.7.0
#tool nuget:?package=Bodu.Financial&version=0.7.0
Bodu.Financial
API stability — Stable. The public API surface is committed; breaking changes are reserved for a major-version bump per SemVer.
Financial primitives for .NET — runtime-tagged and strongly-typed money, the full ISO 4217 currency catalogue (with numeric codes and an active-currency enum), multi-currency aggregates, and a foreign-exchange provider stack with both runtime and typed rate forms.
References Bodu.Numerics for the exact-arithmetic escape hatch (Money<T>.ToFraction() / FromFraction / MultiplyExact) through Fraction<BigInteger>.
Installation
dotnet add package Bodu.Financial
Targets net8.0.
Money — runtime-tagged primary
The runtime Money type carries the currency as ISO 4217 data alongside the amount. Use it when the currency varies at runtime — bank-feed imports, broker exports, JSON DTOs, multi-currency portfolios, ledger reads. Cross-currency operations surface InvalidOperationException at runtime.
using Bodu.Financial;
using Bodu.Financial.Currencies;
Money price = Money.From(19.995m, CurrencyCode.AUD); // rounds to 20.00 (2 minor units)
Money tax = price * 0.1m; // 2.00 AUD
Money total = price + tax; // 22.00 AUD
// Money.From(...) overloads: string, CurrencyInfo, CurrencyCode
Money usd = Money.From(50m, "USD");
Money eur = Money.From(100m, CurrencyRegistry.Get("EUR"));
Highlights:
- Construction by ISO code,
CurrencyInfo, or the generatedCurrencyCodeenum. - Same-currency arithmetic and comparison; mixed-currency operations throw
InvalidOperationException. Allocate(int parts)/Allocate(ReadOnlySpan<decimal> ratios)distribute integer minor-unit shares that sum exactly to the original, sign-stable.- Round-trip parsing and rich formatting — see format specifiers.
RoundToCash(MidpointRounding)snaps to cash denominations.- Bridge to the typed form via
money.As<TCurrency>()/money.TryAs<TCurrency>(out)and the(Money<USD>)moneyexplicit cast.
Money<TCurrency> — strongly-typed specialisation
When an operation, account, or contract is invalid in any currency other than one fixed by the domain, use Money<TCurrency>. The currency is encoded as a type parameter via an ICurrency tag, so cross-currency arithmetic is a compile error rather than a runtime exception.
Money<USD> price = Money.Of<USD>(19.995m); // rounds to 20.00 (2 minor units)
Money<USD> tax = price * 0.1m; // 2.00
Money<USD> total = price + tax; // 22.00
// Money<USD> + Money<JPY> // compile error — currencies cannot be mixed
Money runtime = total; // implicit upcast to runtime Money
Money<USD> back = (Money<USD>)runtime; // explicit; throws on currency mismatch
Highlights:
- Same-currency arithmetic and comparison operators, scalar multiply/divide (
Money<T> * decimal), and dimensionless ratio (Money<T> / Money<T> → decimal). Allocate(int parts)/Allocate(ReadOnlySpan<decimal> ratios)— same semantics as the runtime form.- Cross-currency conversion via either a decimal rate (
Convert<TTarget>(rate, rounding)) or a typed rate (Convert(ExchangeRate<TCurrency, TQuote>, rounding)); the typed form catches direction errors at compile time. - Exact-arithmetic escape via
ToFraction()/FromFraction(...)/MultiplyExact(...)so chained calculations defer rounding to the final step. - Rich formatting (
IFormattable/ISpanFormattable/IUtf8SpanFormattable) — shared specifier vocabulary withMoney. RoundToCash(MidpointRounding)snaps to cash denominations (CHF 5 rappen, CAD/AUD 5¢, NZD 10¢, SEK/NOK whole-krona).
When to use which
| Scenario | Type |
|---|---|
| Deserialising JSON / database row / bank feed | Money |
| Generic invoicing engine accepting multiple currencies | Money |
| Multi-currency portfolio | Money (+ MoneyBag for aggregation) |
| Account fixed to a single currency | Money<TCurrency> |
| Tax calculation in a single jurisdiction | Money<TCurrency> |
| FX conversion contract pinned to specific base/quote | Money<TCurrency> + ExchangeRate<TBase, TQuote> |
| Generic FX conversion service over runtime pairs | Money + ExchangeRate |
Bridge between them: typed → runtime is implicit (lossless); runtime → typed is explicit via As<T>() / TryAs<T>(...) / (Money<T>)money.
Settlement vs calculation
There are three precision models; pick by how rounding should behave:
| Type | Purpose | Precision | Rounding boundary |
|---|---|---|---|
Money / Money<TCurrency> |
Settlement amount — safe to persist, display, post | Rounded to the currency's minor units | At every construction/operation |
CalculatedMoney |
Intermediate calculation chain (interest, unit-rate, apportionment) | High-precision decimal (not exact rational) |
Once, at RoundToMoney(...) |
Fraction APIs (Money<TCurrency>.FromFraction / MultiplyExact) |
Calculations that must be mathematically exact | Exact rational | Once, on conversion to Money<TCurrency> |
Scalar * and / on Money use banker's rounding (MidpointRounding.ToEven); use Multiply(factor, rounding) / Divide(divisor, rounding) for any other rule.
CurrencyInfo and CurrencyCode
CurrencyInfo is the canonical runtime metadata record: ISO 4217 alphabetic and numeric codes, minor-unit precision, cash-rounding increment, historicity flag, demonetization date, English name. Looked up via CurrencyRegistry.Get("AUD") / TryGet(...); custom currencies register via CurrencyRegistry.Register(...).
CurrencyCode is a source-generated enum over the active ISO 4217 currencies — discoverable via IntelliSense, type-safe at compile time. Each member is the three-letter ISO code; each value is the ISO 4217 numeric code.
CurrencyInfo aud = CurrencyInfo.FromCurrencyCode(CurrencyCode.AUD);
bool ok = CurrencyInfo.TryGetCurrencyCode("AUD", out CurrencyCode code);
CurrencyCode jpy = aud.ToCurrencyCode(); // throws for historic / custom currencies
Historic currencies (DEM, FRF, ITL, etc.) remain accessible through CurrencyRegistry and the Bodu.Financial.Currencies tag classes; they are intentionally excluded from CurrencyCode to keep the enum stable when ISO retires a code.
Foreign exchange
The exchange-rate surface lives in the Bodu.Financial.ExchangeRates namespace (using Bodu.Financial.ExchangeRates;). Both runtime and typed rate forms are first-class:
// Runtime rate — direction is data
var runtime = new ExchangeRate("USD", "AUD", new DateOnly(2026, 1, 15), 1.52m, "ECB");
// Typed rate — direction is fixed at compile time; reverse direction is a compile error
var typed = new ExchangeRate<USD, AUD>(1.52m, new DateOnly(2026, 1, 15), "ECB");
Money<USD> source = Money.Of<USD>(100m);
Money<AUD> result = source.Convert(typed); // typed Convert overload
ExchangeRate<AUD, USD> reverse = typed.Inverse(); // reciprocal rate, typed
Bridge between forms via typed.ToRuntime() and ExchangeRate<TBase, TQuote>.FromRuntime(runtime) (the latter throws when the runtime ISO codes do not match the type parameters).
Provider stack:
- Timeless:
IRateProviderwithFixedRateTable(inverse-rate fallback). - Dated:
IDatedRateProvider,FixedDatedRateProvider,RateBook,RateSeries, andDatedRateProviderAdapter. Grouping several providers (priority-fallback, averaging, per-pair routing) lives inBodu.Financial.ExchangeRates.CachingasAggregatingRateProvider.
Lookup surface — IDatedRateProvider exposes a symmetric matrix of single-date and range getters, each with a synchronous and an asynchronous form:
- Single-date
GetRate/GetRateAsync(timeless and dated overloads) return aRateLookupResult— the resolvedExchangeRateplus theRequestedDate, theRateDateResolutionthat was applied, and theOffsetDaysbetween the requested and resolved dates. Resolution and tolerance are controlled per call throughRateLookupOptions. - Range
GetRates/GetRatesAsyncreturn aRateRangeResult: the observations within the window ordered by date, the requested window (RequestedStartDate/RequestedEndDate), and the observed span (FirstObservedDate/LastObservedDate, plusCount/IsEmpty). The result is the rate sequence — it implementsIReadOnlyList<ExchangeRate>, so it indexes, enumerates, and composes with LINQ directly, while comparing the requested window to the observed span reveals how much of the window carried data.
HTTP-backed providers (Bodu.Financial.ExchangeRates.{Yahoo,Boe,Ecb,Rba,Ofx,Xe,Oanda,Fixer,ExchangeRateHost,Fred,Imf}) share the WebRateProvider base from the separate Bodu.Financial.ExchangeRates infrastructure package (this core package carries no HTTP machinery), which centralizes accumulation, the snapshot/lookup matrix, on-demand fetch coalescing, and the HistoryAvailability each provider advertises (how far back it serves rates — unbounded, a fixed earliest date, or a rolling window such as OANDA's ~180 days). Each provider offers two constructors and is IDisposable:
new XProvider(options, ...)— the provider builds, owns, and disposes its ownHttpClient(created viaRateProviderHttpClientFactory.Create). Dispose the provider to release it.new XProvider(httpClient, options, ...)— the caller supplies the client and owns its lifetime; the provider never disposes a borrowed client. This is the form the*.DependencyInjectionpackages use withIHttpClientFactory.
MoneyBag
Immutable mixed-currency aggregate for portfolios and ledgers. Per-currency balances are tracked separately, zero balances pruned automatically, and the bag converts to a single target currency through IRateProvider.
var bag = new MoneyBag(new[]
{
Money.From(100m, "USD"),
Money.From(85m, "EUR"),
Money.Of<JPY>(15_000),
});
Allocation
Both Money and Money<TCurrency> support exact integer-minor-unit allocation that sums to the original amount:
Money split = Money.From(0.10m, "USD");
Money[] shares = split.Allocate(3); // [0.04, 0.03, 0.03]
Money pro = Money.From(100m, "USD");
Money[] proportional = pro.Allocate(new[] { 1m, 2m, 3m }); // largest-remainder method
Format specifiers
Money and Money<TCurrency> share a case-insensitive specifier vocabulary:
| Specifier | Behaviour | Example (Money.From(1234.56m, "USD"), en-US) |
|---|---|---|
G, (null), "" |
ISO code + culture-grouped number | USD 1,234.56 |
C |
Culture-native symbol; ISO-substituted in the culture's currency-position slot on culture/currency mismatch | $1,234.56 (Money carrying JPY in en-US: JPY 1,234) |
L |
Amount followed by the English currency name; falls back to ISO when no name is supplied | 1,234.56 US Dollar |
R |
Invariant round-trip (always invariant culture, no grouping, natural precision); ignores provider; rejects ~ and precision suffixes |
USD 1234.56 |
N |
Number with culture grouping, no currency designator | 1,234.56 |
F, D |
Fixed-point, no grouping, no currency designator | 1234.56 |
- Prefix
~onC,G, orLelides the currency designator when the culture's region currency matches the money's currency, while keeping it when they differ. - A numeric suffix on
C,G,L,N,F, orDoverrides the currency's natural precision (e.g."C0"→$20,"L4"→19.9900 US Dollar).
Compact-notation overloads (ToCompactString(...)) add a K/M/B/T magnitude suffix to the numeric portion, preserving the chosen specifier's symbol position.
Format-specifier reference.
Gis the ISO-code-prefixed form,Cis the culture-native symbol form,Lemits the English currency name, andRis the invariant round-trip form. (Earlier builds spelled the ISO-code-prefix formCand the culture-native formL; code written against those should renameC→GandL→C.)
Serialization
Bodu.Financial is serialization-agnostic — the monetary types carry no [JsonConverter] attribute and the core library ships no System.Text.Json integration of its own. JSON converters for Money, Money<TCurrency>, MoneyBag, ExchangeRate, and CurrencyPair live in the companion Bodu.Financial.Serialization.Json package; registration is required:
using Bodu.Financial.Serialization.Json;
var options = new JsonSerializerOptions().AddFinancialJsonConverters(FinancialJsonPolicy.Strict);
Three policies control the wire shape:
- Strict (default) — object form
{ "amount": 19.99, "currency": "AUD" }; rejects duplicate keys and mismatched / lowercase ISO codes. - Lenient — accepts lowercase currency codes and surrounding whitespace.
- Compact — accepts the round-trip string form
"19.99 AUD".
Migration note. Earlier builds shipped the converters inside
Bodu.Financialand serialized through type-level[JsonConverter]attributes with zero configuration. Without the registration above,JsonSerializernow falls back to reflection-shaped output instead of the canonical shapes.
Currency catalogue
Covers the ~155 active ISO 4217 currencies plus 29 historic / demonetised currencies under Bodu.Financial.Currencies (USD, EUR, GBP, JPY, BHD, KWD, the Euro-zone predecessors DEM / FRF / ITL / ESP / …, VEF, ZWL, …). Tag types, the CurrencyCode enum, and the metadata registration list are source-generated from currencies.json by tools/CurrencyCatalogueGenerator; consumers add custom currencies by implementing ICurrency directly and registering them on CurrencyRegistry.
Runnable samples
The repository ships offline, dotnet run-able sample projects for the financial packages — money basics, static-file rate providers, caching, aggregation, DI wiring, and a custom provider with contract tests — under samples/Financial/.
License
MIT. © Bodu Pty. Ltd.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Bodu.Core (>= 0.7.0)
- Bodu.Numerics (>= 0.7.0)
NuGet packages (15)
Showing the top 5 NuGet packages that depend on Bodu.Financial:
| Package | Downloads |
|---|---|
|
Bodu.Financial.DependencyInjection
Microsoft.Extensions.DependencyInjection registration helpers for Bodu.Financial. Configures named monetary contexts and FinancialOptions through IFinancialServiceBuilder, binding currency, rounding, and allocation defaults into the standard options and configuration pipeline. |
|
|
Bodu.Financial.ExchangeRates
Web exchange-rate provider infrastructure for Bodu.Financial. Provides the WebRateProvider and PairWebRateProvider base classes that per-source provider packages (BoE, ECB, RBA, Yahoo, OFX, XE, OANDA) build on, plus the shared fetch machinery: single-flight request coalescing, file-system response caching, HttpClient construction, and pair-load contracts. Depends only on Bodu.Financial and Microsoft.Extensions.Logging.Abstractions. |
|
|
Bodu.Financial.Serialization.Json
System.Text.Json integration for Bodu.Financial. Provides converters, a shared serialization policy (FinancialJsonPolicy: Strict/Lenient/Compact), the AddFinancialJsonConverters() registration helper for Money, Money<TCurrency>, MoneyBag, ExchangeRate, and CurrencyPair, and the AddFinancialJson() dependency-injection registration. Keeping JSON support in this companion package leaves the core Bodu.Financial library serialization-agnostic. |
|
|
Bodu.Financial.ExchangeRates.Caching
Caching layer for Bodu.Financial exchange-rate providers. Provides CachingRateProvider, a decorator that wraps any IDatedRateProvider and serves fresh rates from a shared on-disk cache (one TOML file per provider and currency pair), plus an AggregatingRateProvider (priority-fallback, averaging, per-pair routing). Includes the dependency-injection registrations (AddCachedRateProvider, AddAggregatedRateProvider) on IFinancialServiceBuilder. |
|
|
Bodu.Financial.ExchangeRates.Rba
Reserve Bank of Australia (RBA) historical exchange-rate provider for Bodu.Financial. Downloads and parses the RBA's published daily .xls files and serves them as Bodu.Financial.ExchangeRates.ExchangeRate values through IDatedRateProvider and IRateProvider, with an async range API and in-memory plus on-disk caching. Includes the dependency-injection registration extensions (AddRbaExchangeRates) that register the provider as a singleton backed by a configured HttpClient and bind RbaRateProviderOptions through Microsoft.Extensions.Options. |
GitHub repositories
This package is not used by any popular GitHub repositories.