Working with Money<TCurrency>
Money<TCurrency> is an immutable, value-equatable monetary amount
whose currency is encoded in the type parameter, not stored as a
runtime field. This shifts the compiler from "no idea what currency
this is" to "knows at every call site," so the obvious error -
adding USD to JPY - fails the build instead of slipping through to
production.
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
The ICurrency tag types
Every currency ships as a sealed tag class in
Bodu.Financial.Currencies. The class only exists to carry the static
metadata Money<TCurrency> needs (the ISO 4217 code and minor-unit
precision) - there is no instance to create:
public sealed class USD : ICurrency
{
public static string IsoCode => "USD";
public static int MinorUnits => 2;
private USD() { }
}
The shipped catalogue covers 155 active ISO 4217 currencies (plus 29 historic ones), including all three minor-unit categories:
MinorUnits = 0-JPY,KRW,CLP,ISK,VND,XAF,XOF, etc.MinorUnits = 2-USD,EUR,GBP,AUD,CAD,CHF, and the vast majority of others.MinorUnits = 3-BHD,IQD,JOD,KWD,LYD,OMR,TND.
The bundled tags are not privileged: you can declare your own type
implementing ICurrency - its IsoCode must be three uppercase ASCII
letters - to mint a Money<TCurrency> for a unit outside the shipped
set. Such a tag stays in the generic world; bridging it to the
runtime-tagged Money requires a code the shipped CurrencyCode
catalogue defines (see Runtime-tagged amounts).
Creating amounts
The primary constructor rounds the amount to the currency's
minor-unit precision using banker's rounding
(MidpointRounding.ToEven):
new Money<USD>(1.235m); // 1.24m - banker's rounding
new Money<USD>(1.245m); // 1.24m - round-half-to-even
new Money<JPY>(99.6m); // 100m - JPY has 0 minor units
new Money<BHD>(12.3456m); // 12.346m - BHD has 3 minor units
For a different rounding rule, pass it explicitly:
new Money<USD>(1.235m, MidpointRounding.AwayFromZero); // 1.24m
new Money<USD>(1.225m, MidpointRounding.AwayFromZero); // 1.23m
A non-generic helper class makes the syntax less noisy when you
already have a using for the currency tag:
using static Bodu.Financial.Money;
using Bodu.Financial.Currencies;
var price = Of<USD>(19.99m);
var zero = Zero<JPY>();
default(Money<TCurrency>) represents zero of the currency, so
Money<USD> total = default; is the idiomatic accumulator
initializer.
Same-currency arithmetic
Addition, subtraction, comparison, and unary negation require the
same TCurrency on both sides - that's the whole point of the
type-parameter design. Scalar multiplication and division accept a
decimal and round the result to the minor-unit precision:
Money<USD> total = Of<USD>(54.30m) + Of<USD>(8.20m);
Money<USD> doubled = Of<USD>(19.99m) * 2m;
Money<USD> share = Of<USD>(10m) / 3m; // 3.33m - rounded
// Money / Money produces a dimensionless ratio:
decimal ratio = Of<USD>(10m) / Of<USD>(4m); // 2.5m
Note that scalar division rounds at every call. For splitting an
amount into shares while preserving the original total exactly, use
Allocate instead (see below).
Money extension helpers
The Bodu.Financial.Extensions namespace adds the small predicates and
helpers that arithmetic alone does not give you. On both Money<TCurrency>
(MoneyOfTCurrencyExtensions) and the
runtime-tagged Money (MoneyExtensions),
Abs, Sign, IsZero, IsNegative, and IsPositive are extension
properties - the library is built with the C# 14 compiler, so they read
without parentheses - and Clamp, Min, and Max are plain static
helpers on the typed class, because a two-operand comparison reads better
unprefixed than as a.Max(b):
using Bodu.Financial;
using Bodu.Financial.Currencies;
using Bodu.Financial.Extensions;
var balance = new Money<USD>(-42.50m);
// Extension properties (C# 14): no parentheses.
Console.WriteLine(balance.Abs); // USD 42.50
Console.WriteLine(balance.Sign); // -1
Console.WriteLine(balance.IsNegative); // True
Console.WriteLine(balance.IsPositive); // False
Console.WriteLine(Money<USD>.Zero.IsZero); // True
// Clamp / Min / Max are plain static helpers (two-operand comparisons read better unprefixed).
var floor = new Money<USD>(0m);
var ceiling = new Money<USD>(25m);
Money<USD> fee = MoneyOfTCurrencyExtensions.Clamp(new Money<USD>(31.20m), floor, ceiling); // USD 25.00
Money<USD> larger = MoneyOfTCurrencyExtensions.Max(new Money<USD>(3m), new Money<USD>(7m)); // USD 7.00
Money<USD> smaller = MoneyOfTCurrencyExtensions.Min(new Money<USD>(3m), new Money<USD>(7m)); // USD 3.00
// The runtime-tagged Money has the same predicates.
var runtime = new Money(-9.99m, CurrencyCode.EUR);
Console.WriteLine(runtime.Abs.IsPositive); // True
| Helper | Money<TCurrency> |
Money |
Notes |
|---|---|---|---|
Abs |
property | property | |Amount|, same currency. |
Sign |
property | property | -1, 0, or 1, as Math.Sign. |
IsZero / IsNegative / IsPositive |
property | property | Strict comparisons against zero. |
Clamp(value, min, max) |
static | - | Inclusive range; ArgumentException when min exceeds max. |
Min(left, right) / Max(left, right) |
static | - | The smaller / larger of two amounts. |
ToCompactString(format, provider, precision) |
method | method | Abbreviated K / M / B rendering - see Compact formatting. |
Note
A build of the library made with an SDK older than .NET 10 compiles the same members as
classic extension methods (balance.Abs(), balance.IsZero()); the shipped packages use
the property form. Clamp, Min, and Max are static in both.
The typed conversion helpers that pair Money<TCurrency> with a rate provider -
ConvertTo<TSource, TTarget> and ConvertToWithRate<TSource, TTarget> in
MoneyOfTCurrencyExchangeRateExtensions - live in the same
namespace; see Audit-grade conversion
in the exchange-rates guide.
Cross-currency conversion
There is no implicit conversion. To cross currencies, call
Convert<TTarget>(rate) and rounding is applied to the destination
precision:
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)
The rate must be non-negative; the rounding rule defaults to
MidpointRounding.ToEven and can be overridden.
Allocation
Allocate(parts) splits the amount into the requested number of
shares whose sum equals the original - the residual minor units are
distributed one per share from the start of the array:
Money<USD>[] shares = new Money<USD>(0.10m).Allocate(3);
// [0.04, 0.03, 0.03] - sums to exactly 0.10
This is sign-stable: a negative amount distributes the residual in the same direction.
Money<USD>[] losses = new Money<USD>(-10m).Allocate(3);
// [-3.34, -3.33, -3.33] - sums to exactly -10
Ratio-based allocation handles weighted splits:
decimal[] ratios = { 1m, 1m, 2m };
Money<USD>[] split = new Money<USD>(100m).Allocate(ratios);
// [25.00, 25.00, 50.00]
Allocation throws ArgumentException for empty, negative, or
all-zero ratios; Allocate(int) throws
ArgumentOutOfRangeException for zero or negative parts.
The residual-distribution rule above is the
AllocationPolicy LargestRemainder strategy:
each leftover minor unit is handed to the share with the largest
fractional remainder (here, one per share from the start), so the parts
always sum back to the original amount with no penny lost or invented.
Allocation in depth
The two Allocate overloads above cover the common cases; this section pins
down the edge behaviour and how allocation interacts with MoneyBag and the
MonetaryContext.
The residual rule is a fixed policy, not a parameter. Both overloads
distribute leftover minor units by the largest-remainder (Hamilton) method -
each slot receives one extra unit in descending order of its fractional
remainder, ties broken by ascending input order. That algorithm is the single
member of AllocationPolicy (LargestRemainder), and it is
also the value carried by MonetaryContext through its
Allocation property. Note that Allocate(int) and
Allocate(ReadOnlySpan<decimal>) do not take a MonetaryContext - they
always round to the currency's own minor-unit precision and always sum back to
the original. The context's Allocation policy documents the strategy the
library applies; it is not a per-call knob on these methods today.
Sign is preserved; the residual flows in the amount's direction. A negative total distributes its leftover units the same way a positive one does, so the parts still sum exactly:
new Money<USD>(10m).Allocate(3); // [3.34, 3.33, 3.33] → 10.00
new Money<USD>(-10m).Allocate(3); // [-3.34, -3.33, -3.33] → -10.00
Zero-ratio slots and small amounts. A zero weight always produces a zero share and never receives a residual unit; a positive total with fewer minor units than parts fills the leading slots and leaves the rest at zero:
decimal[] ratios = { 0m, 1m, 1m };
new Money<USD>(0.03m).Allocate(ratios); // [0.00, 0.02, 0.01]
new Money<USD>(0.02m).Allocate(5); // [0.01, 0.01, 0, 0, 0]
Ratios that are all zero, contain a negative weight, or are empty throw
ArgumentException; Allocate(0) or a negative part count throws
ArgumentOutOfRangeException.
MoneyBag aggregates across currencies but does not allocate them as a unit.
A MoneyBag tracks one balance per currency, so to split a
multi-currency position you allocate each currency's slot independently - each
Allocate call sums back exactly within its own currency:
MoneyBag bag = MoneyBag.Empty
.Add(new Money<USD>(100m))
.Add(new Money<EUR>(99m));
Money<USD>[] usdSplit = bag.GetBalance<USD>()!.Value.Allocate(3); // [33.34, 33.33, 33.33]
Money<EUR>[] eurSplit = bag.GetBalance<EUR>()!.Value.Allocate(3); // [33.00, 33.00, 33.00]
Where MonetaryContext does change rounding. The context governs the
operation boundaries - multiplication, division, conversion, and the
settlement of a CalculatedMoney - not the residual distribution. So when you
need a non-banker's rounding rule before splitting, apply it at the multiply step
and allocate the rounded result, which then sums back exactly under the fixed
largest-remainder rule:
MonetaryContext awayFromZero = MonetaryContext.Default with
{
Rounding = new MidpointRoundingStrategy(MidpointRounding.AwayFromZero),
};
Money<USD> commission = new Money<USD>(17m).Multiply(0.125m, awayFromZero); // 2.13 USD (banker's → 2.12)
Money<USD>[] shares = commission.Allocate(3); // sums to 2.13
MoneyBag.ConvertTo<TTarget> takes a related but separate
MoneyBagConversionRoundingPolicy - SumRawThenRound
(round once after summing every converted balance, the default) or
RoundEachCurrencyThenSum (round each converted balance first) - which decides
where the single rounding event lands when collapsing a bag to one currency.
Exact arithmetic for long chains
Every operation that needs to round (*, /, Convert) rounds at
the call site. When a calculation chains several such steps, the
errors accumulate. For exact intermediate arithmetic - compound
interest, tax stacking, percentage-of-percentage - round-trip
through Fraction<BigInteger>:
Money<USD> principal = new Money<USD>(1000m);
Fraction<BigInteger> monthlyRate = Fraction<BigInteger>.Create(5, 1200); // 5%/12
Fraction<BigInteger> growth = (Fraction<BigInteger>.One + monthlyRate);
Fraction<BigInteger> exact = principal.ToFraction();
for (int i = 0; i < 24; i++)
exact *= growth;
Money<USD> balance = Money<USD>.FromFraction(exact); // one rounding event
The common case - multiply once by a fraction - has a shortcut:
Money<USD> result = principal.MultiplyExact(growth);
Deferred rounding with CalculatedMoney
Fraction<BigInteger> is mathematically exact but heavyweight. When
you only need to defer rounding across a chain of decimal steps -
not full rational exactness - CalculatedMoney is the lighter middle
tier. It is a runtime-tagged, high-precision amount 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
Money<USD> usd = settled.As<USD>();
ToCalculated() is available on both Money<TCurrency> and the
runtime Money, and always returns the runtime CalculatedMoney -
there is no generic CalculatedMoney<TCurrency>. Arithmetic (+, -,
*, /, and the named Multiply / Divide) preserves precision, and
mixing two different currencies throws InvalidOperationException at
runtime. RoundToMoney accepts an optional MonetaryContext to
control the rounding strategy, scale, and cash rounding, or a bare
MidpointRounding:
Money rounded = running.RoundToMoney(MidpointRounding.AwayFromZero);
Pick the tier that fits the calculation: Money<TCurrency> rounds at
every step (settlement-grade), CalculatedMoney defers rounding at
full decimal precision (28-29 significant digits), and
Fraction<BigInteger> is exact. Reach for CalculatedMoney in tax
apportionment and unit-rate products where decimal precision is
sufficient, and for Fraction only when the chain must be exact.
Formatting
ToString() returns the ISO code followed by the amount at minor-unit
precision, with culture-aware grouping:
new Money<USD>(1234.56m).ToString(); // "USD 1,234.56"
new Money<JPY>(2500m).ToString(); // "JPY 2,500"
new Money<BHD>(12.345m).ToString(); // "BHD 12.345"
Supported format specifiers:
| Specifier | Output |
|---|---|
null, "", "G", "C" |
ISO code + grouped number at minor-unit precision |
"C4", "G0" |
ISO code + grouped number with explicit precision |
"L" |
Locale-aware: culture's native currency symbol when matched, ISO code substituted into the locale's pattern when mismatched |
"N" |
Grouped number only, no ISO code |
"F", "D" |
Bare number without grouping or ISO code |
"N4", "F0" |
Same as above with explicit precision |
Prefix "~" on C/G/L |
Elide the currency designator when the culture's currency matches TCurrency |
var m = new Money<USD>(1234.56m);
m.ToString("C", CultureInfo.InvariantCulture); // "USD 1,234.56"
m.ToString("C4", CultureInfo.InvariantCulture); // "USD 1,234.5600"
m.ToString("N", CultureInfo.InvariantCulture); // "1,234.56"
m.ToString("F0", CultureInfo.InvariantCulture); // "1235"
Money<TCurrency> implements IFormattable, ISpanFormattable, and
IUtf8SpanFormattable, so it composes with the high-performance
formatting APIs in modern .NET.
Locale-aware formatting with L
The L specifier renders the amount through the culture's native
NumberFormatInfo.CurrencyPositivePattern - symbol position,
decimal separator, grouping separator, and parenthesised negatives
all follow what the locale would do for decimal.ToString("C"). The
catch is the currency symbol itself: the locale picks a symbol from
its own currency, not yours. To stay unambiguous, L substitutes the
ISO code when the locale's currency differs from TCurrency:
var usd = new Money<USD>(1234.56m);
var jpy = new Money<JPY>(1234m);
var eur = new Money<EUR>(1234.56m);
// Culture's region currency matches - use the local symbol:
usd.ToString("L", new CultureInfo("en-US")); // "$1,234.56"
jpy.ToString("L", new CultureInfo("ja-JP")); // "¥1,234"
eur.ToString("L", new CultureInfo("de-DE")); // "1.234,56 €"
eur.ToString("L", new CultureInfo("fr-FR")); // "1 234,56 €"
// Currencies differ - substitute the ISO code in the locale's slot:
jpy.ToString("L", new CultureInfo("en-US")); // "JPY 1,234"
usd.ToString("L", new CultureInfo("de-DE")); // "1.234,56 USD"
The currency's minor-unit precision wins over the locale's
CurrencyDecimalDigits, so Money<JPY> always formats with zero
fractional digits and Money<BHD> always with three, regardless of
the culture's defaults. Explicit precision suffixes ("L0", "L4")
override both.
The current culture's region is unreachable from a CultureInfo
that has no country (neutral cultures such as "en", "fr") and
from CultureInfo.InvariantCulture; in those cases L falls back
to the ISO-substitution form, which always works regardless of
region.
Eliding the currency when redundant
Prefixing any of C, G, or L with ~ drops the currency
designator only when the culture already implies it - useful for
logs and exports where the active culture is uniform and the ISO
code adds noise on every line, but you still want a guard against a
stray foreign-currency value sneaking through:
var usd = new Money<USD>(1234.56m);
var jpy = new Money<JPY>(1234m);
usd.ToString("~C", new CultureInfo("en-US")); // "1,234.56" - elided
jpy.ToString("~C", new CultureInfo("en-US")); // "JPY 1,234" - kept
usd.ToString("~L", new CultureInfo("en-US")); // "19.99" - elided
jpy.ToString("~L", new CultureInfo("en-US")); // "JPY 1,234" - kept
The "matches" test uses RegionInfo.ISOCurrencySymbol for the
culture passed to the formatter (not CultureInfo.CurrentCulture
unless that's what was passed). Neutral cultures and the invariant
culture never match, so ~ is safe to apply unconditionally - when
the formatter has no region context, the ISO code stays in the
output.
Reusable formatting with MoneyFormatter
The format strings above are convenient for one-offs, but when you need to apply the same formatting decisions repeatedly - across a report, an export, or a UI surface - build a reusable MoneyFormatter once with MoneyFormatterBuilder and call it per value. The formatter operates on the runtime-tagged Money, so it can format any currency through a single instance:
MoneyFormatter formatter = new MoneyFormatterBuilder()
.WithSymbol() // CurrencyDisplay.Symbol
.WithCulture(new CultureInfo("en-US"))
.WithGrouping(includeGrouping: true)
.ElideWhenCultureMatches() // the "~" behaviour, baked in
.Build();
string a = formatter.Format(new Money<USD>(1234.56m).ToMoney()); // "1,234.56"
string b = formatter.Format(new Money<JPY>(1234m).ToMoney()); // "JPY 1,234"
The builder mirrors the format-string options as fluent calls -
WithIsoCode() / WithSymbol() / WithEnglishName() /
WithNumericOnly() select the CurrencyDisplay
mode (IsoCode, Symbol, EnglishName, None); WithCulture,
WithGrouping, WithMinorUnits, and ElideWhenCultureMatches set the
remaining knobs. For ad-hoc construction, set the same fields directly
on a MoneyFormatOptions and pass it to the
MoneyFormatter constructor; MoneyFormatOptions.Default is the
process-wide fallback.
Compact formatting
For dashboards and summaries,
MoneyCompactFormattingExtensions renders large
amounts in abbreviated form (1.2K, 3.4M, 5.6B) directly on both
Money<TCurrency> and Money:
new Money<USD>(1_234_567m).ToCompactString(); // "USD 1.2M" (default "C" specifier)
The optional format, provider, and precision arguments mirror the
Money format specifiers, so ToCompactString("~C", culture) elides a
redundant currency designator exactly as the full formatter does.
Parsing
Parsing is strict: a bare decimal, or "<ISO> <decimal>" /
"<decimal> <ISO>". Currency symbols like $ are rejected because
they are ambiguous across USD, CAD, AUD, SGD, etc.
Money<USD>.Parse("19.99", CultureInfo.InvariantCulture); // OK
Money<USD>.Parse("USD 19.99", CultureInfo.InvariantCulture); // OK
Money<USD>.Parse("19.99 USD", CultureInfo.InvariantCulture); // OK
Money<USD>.Parse("JPY 19.99", CultureInfo.InvariantCulture); // FormatException
Money<USD>.Parse("$19.99", CultureInfo.InvariantCulture); // FormatException
IParsable<Money<TCurrency>> and ISpanParsable<Money<TCurrency>>
are implemented for the generic-math interface set.
Tuning parse behaviour with MoneyParseOptions
The strictness above is the default. To relax or retarget it - for example when importing a spreadsheet column or round-tripping a value your own formatter produced - pass a MoneyParseOptions whose MoneyParseMode selects the policy:
MoneyParseMode |
Accepts |
|---|---|
StrictIso |
A bare decimal or "<ISO> <decimal>" / "<decimal> <ISO>" only. The default. |
CultureAware |
Adds the active culture's number formatting (grouping, decimal separator). |
LenientImport |
Tolerant of spreadsheet / external-feed quirks; for ingest, not canonical storage. |
RoundTripOnly |
Accepts exactly the shape this library emits, for loss-free round trips. |
MoneyParseOptions also carries the FormatProvider and an optional
ICurrencyLookup (consulted for symbol resolution under
CultureAware); an ISO code that does not resolve to a shipped
currency is rejected. The runtime-tagged Money
uses the same options object when parsing a value whose currency is not
known until run time.
JSON
JSON support ships in the companion Bodu.Financial.Serialization.Json
package; register its converters before serializing - the core types
carry no [JsonConverter] attribute:
using Bodu.Financial.Serialization.Json;
var options = new JsonSerializerOptions().AddFinancialJsonConverters();
{ "amount": 19.99, "currency": "USD" }
The deserializer verifies the "currency" field matches
TCurrency.IsoCode and throws JsonException on mismatch - drift
between the persisted currency and the code's expectation surfaces as
an error rather than a silent re-interpretation.
Cash rounding
A handful of currencies round physical cash transactions to a coarser increment than their electronic minor unit. Switzerland's 5-rappen coin, Canada's 5-cent cash totals (after the penny was withdrawn in 2013), Australia's 5-cent cash totals (since 1992), New Zealand's 10-cent rounding (since 2006), and Sweden/Norway's whole-krone cash rounding all fall into this bucket.
The shipped catalogue surfaces the convention through
ICurrency.CashRoundingIncrement (the smallest denomination in the
major unit, or 0m when no special rounding applies). RoundToCash()
snaps an amount to the nearest multiple of that increment using
banker's rounding by default:
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
Pass MidpointRounding.AwayFromZero to round midpoints up instead of
toward the nearest even denomination:
new Money<NZD>(5.05m).RoundToCash(); // NZD 5.00 (banker's down to even)
new Money<NZD>(5.05m).RoundToCash(MidpointRounding.AwayFromZero); // NZD 5.10
Cash rounding is for physical cash totals only - electronic
transactions retain the full MinorUnits precision. Use the method at
the point where the total becomes a cash payment, not at every
intermediate step.
Stochastic rounding
Every rounding rule that always resolves a midpoint the same way - banker's,
away from zero, toward zero - is deterministic and therefore biased over a
long series of operations in one direction: a fee schedule that rounds a
million half-cents down loses half a cent a million times. MonetaryContext
carries the rounding rule as an IRoundingStrategy, and
StochasticRoundingStrategy is the alternative to
MidpointRoundingStrategy: it rounds up with probability
equal to the discarded fraction and down otherwise, so the expected value of
each rounding equals the raw amount and the drift cancels out in aggregate.
That property costs determinism. Two roundings of the same input may differ,
which is exactly what makes the strategy unsuitable for anything a reader must
be able to recompute - an invoice line, a tax figure, a ledger posting - and
suitable for statistical allocations, simulations, and internal rebalancing
where the sum matters more than any one row. The draw comes from an injected
sampler, so a test can pin the direction, and a seeded Random gives a
reproducible sequence:
using Bodu.Financial;
using Bodu.Financial.Currencies;
// Production: unbiased rounding drawn from Random.Shared.
var context = new MonetaryContext { Rounding = StochasticRoundingStrategy.Shared };
var unitPrice = new Money<USD>(0.10m);
Money<USD> lineTotal = unitPrice.Multiply(0.35m, context); // 0.035 rounds to 0.03 or 0.04 - 50/50 here
// Over many roundings the mean converges on the raw amount; a fixed midpoint rule would drift.
decimal raw = 0.035m;
decimal sum = 0m;
for (int i = 0; i < 10_000; i++)
sum += context.Round(raw, 2);
Console.WriteLine($"{lineTotal}; mean {sum / 10_000m:0.0000} vs raw {raw}"); // mean ≈ 0.0350
// Tests: inject a sampler so the direction is deterministic.
var alwaysUp = new StochasticRoundingStrategy(() => 0.0); // draw < fraction ⇒ round up
var alwaysDown = new StochasticRoundingStrategy(() => 0.999); // draw ≥ fraction ⇒ round down
Console.WriteLine(alwaysUp.Round(1.001m, 2)); // 1.01
Console.WriteLine(alwaysDown.Round(1.999m, 2)); // 1.99
// Seeded: reproducible sequence without being a fixed direction.
var random = new Random(42);
var seeded = new StochasticRoundingStrategy(random.NextDouble);
Console.WriteLine(seeded.Round(2.345m, 2));
The rule is applied on the number line - "up" is toward positive infinity -
so negative amounts are unbiased in the same way as positive ones, and a value
already exact at the target scale is returned unchanged. Shared is backed by
Random.Shared and safe for concurrent use; a strategy over your own Random
instance is only as thread-safe as that instance. The sampler must return a
value in [0, 1); the scale must be between 0 and 28.
Historic currencies
The shipped catalogue includes 29 demonetized currencies - the
twenty Euro-zone predecessors (ATS, BEF, CYP, DEM, EEK, ESP, FIM,
FRF, GRD, HRK, IEP, ITL, LTL, LUF, LVL, MTL, NLG, PTE, SIT, SKK) plus
nine other notable replacements (AZM, GHC, MZM, ROL, SRG, TMM, VEB,
VEF, ZWL). They participate in arithmetic and formatting like any
other currency so legacy data remains processable; the difference is
that IsHistoric, DemonetizedOn, and SuccessorIsoCode carry the
withdrawal metadata:
Money<DEM>.IsHistoric; // true
Money<DEM>.DemonetizedOn; // 2002-02-28
Money<DEM>.SuccessorIsoCode; // "EUR"
// Arithmetic still works normally:
Money<DEM> total = new Money<DEM>(100m) + new Money<DEM>(50m); // DEM 150.00
For runtime processing of legacy ledgers - for example, validating
that an imported journal entry's currency was active on its posting
date - read the metadata from CurrencyRegistry:
CurrencyInfo info = CurrencyRegistry.Get(entry.IsoCode);
if (info.IsHistoric && entry.PostedOn > info.DemonetizedOn)
throw new InvalidOperationException(
$"{entry.IsoCode} was demonetized {info.DemonetizedOn:d} (replaced by {info.SuccessorIsoCode}).");
Runtime-tagged amounts: Money
Money is the runtime-tagged sister of Money<TCurrency>. The
currency is carried as a CurrencyCode
field rather than a type parameter, so the same code path handles any
shipped currency at runtime - useful for deserialisation, generic
invoicing engines, and FX systems where the currency comes from data,
not type.
// `options` has the financial converters registered (see the JSON section above).
Money invoice = JsonSerializer.Deserialize<Money>(payload, options)!;
// invoice could be "USD 19.99", "EUR 19.99", or "JPY 200" - same code.
Arithmetic semantics match Money<T> but cross-currency operations
throw InvalidOperationException at runtime instead of failing the
build:
Money usd = new Money(10m, CurrencyCode.USD);
Money eur = new Money(10m, CurrencyCode.EUR);
Money total = usd + new Money(5m, CurrencyCode.USD); // OK
total = usd + eur; // throws InvalidOperationException
Bridge to and from a typed Money<T> when the boundary is known:
Money runtime = new Money<USD>(19.99m).ToMoney(); // typed → runtime-tagged
Money<USD> typed = runtime.As<USD>(); // throws on mismatch
bool ok = runtime.TryAs(out Money<USD> result); // safe, returns false on mismatch
Money rounds to the MinorUnits resolved for its
CurrencyCode on construction. The
runtime currency set is the shipped ISO 4217 catalogue - active and
historic - that the enum enumerates; a code outside it cannot be
constructed, so a mistyped or unsupported currency fails fast rather
than silently adopting a default precision.
Mixed-currency portfolios: MoneyBag
MoneyBag aggregates per-currency balances. Useful for tracking
ledger positions across currencies without silently merging them:
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
Operators chain naturally:
MoneyBag updated = wallet
+ new Money(25m, CurrencyCode.USD)
- new Money(10m, CurrencyCode.EUR);
Bags are immutable; every operation returns a new bag. Zero balances are pruned automatically.
To convert the bag to a single target currency, supply an
IRateProvider:
Dictionary<(string From, string To), decimal> rates = new()
{
{ ("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
FixedRateTable short-circuits same-currency lookups to 1
and falls back to the inverse rate 1 / rate when only the reverse
pair is in the table, so a typical "USD → X" set of rates is enough
to convert in both directions.
Auditable bag conversion
ConvertTo<TTarget> answers "what is this bag worth in AUD?" and nothing
else. When the answer has to be explained - a month-end revaluation, a
customer statement, a reconciliation - ConvertToWithAudit<TTarget> returns
the same total together with one
MoneyBagConversionLine per source currency: the raw
balance, the exact RateLookupResult that
was used (or null for the identity pass-through of the target currency
itself), and the unrounded contribution to the total. Because the lookup
result is carried whole, each line records which provider answered, which
date actually resolved, and how far it was from the date you asked for:
using Bodu.Financial;
using Bodu.Financial.Currencies;
using Bodu.Financial.ExchangeRates;
MoneyBag ledger = MoneyBag.Empty
.Add(new Money<AUD>(1_000m))
.Add(new Money<EUR>(250m))
.Add(new Money<USD>(400m));
IDatedRateProvider rates = new FixedDatedRateProvider(new[]
{
new ExchangeRate(CurrencyCode.EUR, CurrencyCode.AUD, new DateOnly(2024, 3, 14), 1.6520m, "Treasury"),
new ExchangeRate(CurrencyCode.USD, CurrencyCode.AUD, new DateOnly(2024, 3, 15), 1.5180m, "Treasury"),
});
MoneyBagConversionAudit<AUD> audit = ledger.ConvertToWithAudit<AUD>(
rates, new DateOnly(2024, 3, 15), RateLookupOptions.PreviousWithin(3));
Console.WriteLine(audit.Total); // AUD 2,020.20 = 1000 + 250×1.6520 + 400×1.5180
foreach (MoneyBagConversionLine line in audit.Lines) // ISO-lexicographic: AUD, EUR, USD
{
if (line.Rate is null)
{
Console.WriteLine($"{line.SourceIsoCode} {line.SourceAmount} -> identity");
continue;
}
RateLookupResult lookup = line.Rate.Value;
Console.WriteLine(
$"{line.SourceIsoCode} {line.SourceAmount} × {lookup.Rate.Rate} ({lookup.Rate.Provider}, " +
$"{lookup.Resolution} {lookup.Rate.Date:yyyy-MM-dd}, offset {lookup.OffsetDays}d) = {line.RawConvertedAmount}");
}
// AUD 1000 -> identity
// EUR 250 × 1.6520 (Treasury, PreviousOnOrBefore 2024-03-14, offset 1d) = 413.0000
// USD 400 × 1.5180 (Treasury, PreviousOnOrBefore 2024-03-15, offset 0d) = 607.2000
The audit is a readonly record struct of (Total, Lines); Lines is in
ISO-lexicographic order, the same order the bag itself enumerates, so a
report built from it is stable across runs. The dated
IDatedRateProvider is required - an
audit without a resolved date is not one - and any date-resolution policy the
provider accepts can be passed; null means RateLookupOptions.Exact.
The optional last argument, a
MoneyBagConversionRoundingPolicy, decides where the
target currency's rounding happens. SumRawThenRound (the default) adds the
unrounded contributions and rounds once; RoundEachCurrencyThenSum rounds
each line to the target's minor units first. The two differ by at most a few
minor units, but they differ, and a statement whose lines are shown rounded
must add up to its total:
MoneyBag ledger = MoneyBag.Empty.Add(new Money<EUR>(10m)).Add(new Money<USD>(10m));
IDatedRateProvider rates = new FixedDatedRateProvider(new[]
{
new ExchangeRate(CurrencyCode.EUR, CurrencyCode.AUD, new DateOnly(2024, 3, 15), 1.6505m, "Treasury"), // 16.505 raw
new ExchangeRate(CurrencyCode.USD, CurrencyCode.AUD, new DateOnly(2024, 3, 15), 1.5005m, "Treasury"), // 15.005 raw
});
Money<AUD> sumThenRound = ledger.ConvertToWithAudit<AUD>(rates, new DateOnly(2024, 3, 15), null).Total;
Money<AUD> roundEachThenSum = ledger.ConvertToWithAudit<AUD>(
rates, new DateOnly(2024, 3, 15), null, MoneyBagConversionRoundingPolicy.RoundEachCurrencyThenSum).Total;
Console.WriteLine($"{sumThenRound} vs {roundEachThenSum}"); // AUD 31.51 vs AUD 31.50 - the half-cents round away separately
The same policy parameter is accepted by the plain ConvertTo<TTarget> overloads,
so a bag can be totalled line-rounded without producing the audit.
Currencies outside the shipped catalogue
The runtime Money identifies its currency with the
CurrencyCode enum, which enumerates the
full ISO 4217 set - every active code plus the historic ones above. That
set is closed: there is no runtime registration seam, so a code the enum
does not define cannot be constructed as a Money. The trade-off is
deliberate - a mistyped or unsupported currency fails fast instead of
flowing through the system as a silently accepted value.
For a generic amount in a unit outside that set - a commodity weight, a
loyalty-point unit, a pre-decimal currency - declare your own ICurrency
tag and use Money<TCurrency>. The tag supplies its own precision and
never consults the runtime catalogue:
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); // generic arithmetic only
A custom tag's IsoCode must be three uppercase ASCII letters, and the
value stays in the generic world: because XPT is not a CurrencyCode
member, it cannot bridge to the runtime-tagged Money. To substitute or
restrict the metadata used for the shipped currencies - for a test, or
an alternate data source - install a custom ICurrencyLookup through
CurrencyResolution (next section).
Swapping the currency catalogue: CurrencyResolution
Runtime Money resolves its CurrencyCode
to minor-unit precision through an ambient ICurrencyLookup, exposed
as CurrencyResolution.Current. By default this is a registry-backed
lookup, so ordinary construction and metadata resolution behave exactly
as described above - you only need this seam when you want to substitute
the catalogue (a custom data source, or a fixed set for a test).
Replace the process-wide default once at start-up:
CurrencyResolution.SetDefault(myCurrencyLookup);
Or install a temporary, flow-scoped override - ideal for tests, since it is restored on dispose and isolated per async control flow:
using (CurrencyResolution.PushScoped(myCurrencyLookup))
{
// Money construction, parsing, and formatting in this scope
// resolve currency metadata through myCurrencyLookup.
var m = new Money(1.239m, CurrencyCode.BHD); // precision via the scoped catalogue
} // previous lookup restored here
Money<TCurrency> is unaffected - its precision comes from the
TCurrency tag, not the ambient lookup. Only the runtime Money
resolution paths (construction, MinorUnits, From, parsing, and
formatting) consult CurrencyResolution.Current.
When you compose the library through dependency injection, register an
ICurrencyLookup and promote it to the ambient default after building
the provider:
using Bodu.Financial;
using Microsoft.Extensions.DependencyInjection;
services.AddFinancialService(b => b.AddCurrencyLookup<MyCurrencyLookup>());
// ...
IServiceProvider provider = services.BuildServiceProvider();
provider.UseCurrencyResolution(); // ambient default = the DI lookup
JSON wire shape
With the Bodu.Financial.Serialization.Json converters registered
(options.AddFinancialJsonConverters()), Money<TCurrency> and
Money both serialise as:
{ "amount": 19.99, "currency": "USD" }
Deserialisation on Money<TCurrency> rejects payloads whose
"currency" field does not match TCurrency.IsoCode -
currency drift surfaces as JsonException, not as a silently
re-interpreted amount. Money accepts any code the shipped
CurrencyCode catalogue defines, rounding to that currency's
MinorUnits, and rejects one it does not - an unknown or custom
code in the payload throws rather than deserialising.
MoneyBag uses a { "balances": { ... } } wrapper:
{ "balances": { "USD": 100.00, "EUR": 50.00, "JPY": 10000 } }
Amounts are emitted as JSON numbers; the reader also accepts string amounts to round-trip large values through systems that lack arbitrary-precision number support.
To switch the wire shape, register the converters under an explicit
FinancialJsonPolicy. The Compact
policy collapses each money to a single string and a bag to a flat
ISO-keyed object - smaller on the wire and readable in a log line:
using Bodu.Financial.Serialization.Json;
var options = new JsonSerializerOptions();
options.AddFinancialJsonConverters(FinancialJsonPolicy.Compact);
JsonSerializer.Serialize(new Money<USD>(19.99m), options); // "19.99 USD"
JsonSerializer.Serialize(wallet, options); // { "USD": 100.00, "EUR": 50.00 }
Lenient keeps the Strict shape but normalises lowercase ISO codes
to uppercase and trims surrounding whitespace before validation - for
ingesting spreadsheets and external feeds, not as a canonical storage
shape. The same call registers converters for ExchangeRate
and CurrencyPair too.
A Money carrying an explicit minor-unit scale - a unit price finer
than the currency's registered precision - additionally emits a
scale property in the object shape
({ "amount": 145.678912, "currency": "USD", "scale": 6 }) so the
precision survives the round-trip; unrounded CalculatedMoney
serialises its full decimal verbatim. See
Monetary precision & unit pricing.
When not to use Money<TCurrency>
- Calculations that genuinely span unknown currencies. When you
cannot fix the currency at the type-system level (for example, a
generic invoicing engine that handles arbitrary user-supplied
currencies), use
Moneyinstead - the trade-off is runtime cross-currency checks rather than compile-time ones. - Mixed-currency totals. Use
MoneyBagand a singleConvertTo<TTarget>call at the boundary where the total is materialised. - Storage of foreign-exchange spot rates or other ratios. Use
Fraction<BigInteger>directly - those values are dimensionless and benefit from exact rational arithmetic. - Sub-minor-unit precision.
Money<TCurrency>rounds to the currency's minor-unit precision on construction. For unit prices at a known finer scale (for example, half-pennies in gas pricing or a six-place share price), use the runtime-taggedMoneywith an explicit scale viaMoney.FromExplicitScale, or carry the calculation inCalculatedMoney- see Monetary precision & unit pricing. For exact multi-step arithmetic, promote toFraction<BigInteger>and snap toMoney<TCurrency>only at the persistence boundary.
See also
Money<TCurrency>API referenceMoneyAPI referenceMoneyBagAPI referenceCalculatedMoneyAPI reference - the deferred-rounding tier.CurrencyRegistryCurrencyResolution- the ambient currency-lookup seam.IRateProviderMoneystatic factory helpersICurrencyinterfaceFraction<T>API reference - the exact-arithmetic escape hatch.- Numerics & Financial guides - every guide in this topic, across Bodu.Numerics and Bodu.Financial.