Skip to content

Integrate Money with .Net's formatting and parsing infrastructure - #90

Open
matthew25187 wants to merge 9 commits into
developfrom
feature/money-formatting
Open

Integrate Money with .Net's formatting and parsing infrastructure#90
matthew25187 wants to merge 9 commits into
developfrom
feature/money-formatting

Conversation

@matthew25187

Copy link
Copy Markdown
Owner

Adds native formatting and parsing support to the Money type, so that a monetary value can be formatted and parsed exactly as an intrinsic numeric type is.

var money = Money.Create(1234.5m, Iso4217CurrencyCurrent.NZD);

money.ToString("C2", new CultureInfo("en-NZ"));   // NZ$1,234.50
money.ToString("I2", new CultureInfo("de-DE"));   // 1.234,50 NZD
Money.Parse("NZD1,234.50", enNz);                 // round-trips

Breaking change

C now emits the currency symbol rather than the ISO 4217 currency code. Use I where you previously used C.

Format Before After
money.ToString("C2", enNz) NZD1,234.50 NZ$1,234.50
money.ToString("I2", enNz) NZD1,234.50

The mapping now matches the meaning the specifier has for the intrinsic numeric types, so a caller writing money.ToString("C2", culture) gets what decimal.ToString("C2", culture) would give them. This warrants a major version bump for the package.

Format specifiers

Specifier Emits Example (NZD, en-NZ)
C Currency symbol NZ$1,234.50
H Narrow currency symbol $1,234.50
I ISO 4217 currency code NZD1,234.50
N Currency name New Zealand Dollar1,234.50
G, or no format The amount alone 1234.5
currency code The code, asserting the currency NZD1,234.50

Each may carry a precision (C2, I3); the default is the number of minor units of the currency.

The culture governs presentation, not currency

A Money value carries its own currency, so the culture supplied determines only the separators, group sizes, token placement and sign. Which currency is denoted, and the default precision, come from the value.

Money.Create(1234567.5m, Iso4217CurrencyCurrent.NZD).ToString("C", enIn);   // NZ$12,34,567.50

The amount is grouped in the Indian style because that culture was asked for, but the value is still denoted in New Zealand Dollars. Substituting the culture's symbol would assert that a quantity of New Zealand Dollars is the same quantity of Rupees, which is false and which neither a reader nor a subsequent parse could detect.

Parsing

A currency is identified from a currency code, which is never ambiguous, or from a symbol where the currency it denotes can be established with certainty. Measured against CLDR, 159 of the 164 currencies carrying a symbol are resolvable without any culture at all; only GBP, JPY, KRW, USD and VEF need one, being the currencies holding a bare shared glyph.

Where the currency cannot be determined, parsing fails. It never selects one on the value's behalf, and never resolves a shared symbol from the culture of the current thread.

MoneyStyles lets a caller accept only the elements they expect, as NumberStyles does for the intrinsic numeric types. ParseExact accepts a value only where formatting the result reproduces it under one of the named formats.

Defects found and fixed

Four pre-existing defects surfaced during the work:

  • Parse silently truncated NZD1,234.50 to 1234. The pattern admitted no group separators, so the minor units were discarded whenever one was present. The most consequential of these.
  • netstandard1.0 did not build, owing to unguarded nullable annotations.
  • MoneyInfo.CurrentMoney threw when first read, interpreting the subtags of the current culture with Enum.TryParse where neither is a System.Enum.
  • CompareTo(object) threw for a Money argument; Equals(object) used the reflection-based comparison on modern targets.

Two further defects were introduced and fixed within the branch: a value with no currency emitted the culture's symbol, and a malformed precision fell through to the number formatter, which emitted the culture's symbol in place of the value's currency.

Currency symbol data

ISO 4217 defines no currency symbols, so they are sourced from Unicode CLDR by a new generator script following the conventions of the existing ones. Iso4217CurrencyCodeAttribute and the existing generators are untouched. Symbols are exposed through GetCurrencySymbol extension methods on both currency code enums, in the standard and narrow forms CLDR publishes.

The Unicode copyright notice required by the licence is recorded in THIRD-PARTY-NOTICES.md and in the generated source.

Notable design decisions

  • CurrencyFormatInfo has no CurrencySymbol. NumberFormatInfo needs one because decimal carries no currency; a Money value does, so the symbol always comes from the value. CurrencyCode is retained because it is what resolves a shared symbol when parsing.
  • CurrencySymbols is internal. Symbols have no unique identity — $ is shared by thirty currencies — so a public symbol type would offer naming without round-tripping.
  • Resource lookup no longer mutates the shared resource manager culture, which was not safe to do from several threads at once.

Verification

  • All four target frameworks build with no errors and no warnings.
  • 395 tests pass, up from 239.
  • Satellite assemblies were confirmed to survive packing (64 assemblies, 16 cultures across 4 frameworks), and a CI step now asserts this. The step was itself tested against a package with the satellites removed, to establish that it fails.

Left to the reviewer

  • The package version bump for the breaking change.
  • The legacy Parse/TryParse overloads now route through the new parser. This fixes the truncation defect but does widen them to accept unambiguous currency symbols. Say if you would prefer them frozen.

🤖 Generated with Claude Code

matthew25187 and others added 9 commits August 9, 2026 17:56
Groundwork for integrating Money with .Net's formatting infrastructure.

Fix the netstandard1.0 build, which failed because MoneyFormatter.Format
used nullable annotations without guarding them for the target frameworks
where nullable reference types are unavailable.

Fix three latent defects in Money: Parse(string, IFormatProvider) did not
negate the amount of a negative value without a currency, diverging from
the three sibling parse methods; CompareTo(object) delegated to the amount
and therefore threw for a Money argument; and Equals(object) used the
reflection-based ValueType comparison on modern targets rather than the
strongly typed comparison used elsewhere.

Cache the ISO 4217 attribute and name lookups. These were resolved by
reflection on every call, which formatting and parsing would otherwise
repeat for every currency of every operation.

Add currency symbols, generated from Unicode CLDR by a new script that
follows the conventions of the existing generators. ISO 4217 defines no
symbols, so CLDR is used as the source; symbols are exposed through
GetCurrencySymbol extension methods on the currency code enums, in both
the standard and narrow forms CLDR publishes. A reverse index supports
resolving a symbol back to a currency where exactly one currency uses it.

Consolidate the loading of currency format information into a single
culture-aware factory, replacing two implementations whose error handling
disagreed. Resource lookup no longer mutates the shared resource manager
culture, which was not safe to do from several threads at once, and the
current-culture instances now track a culture assigned after first use.

Add NegativeSign, which several cultures render with U+2212 rather than
the ASCII hyphen. Remove CurrencySymbol: a Money value carries its own
currency, so the symbol is determined by the value being formatted rather
than by the culture formatting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implement MoneyFormatter, which formats a Money value as a custom
formatter obtained from the format provider chain, and route every
Money.ToString overload through it. The previous implementation formatted
the amount with .Net's currency specifier and then replaced the culture's
currency symbol with the currency code, which failed silently whenever the
symbol was absent from the rendered output or occurred more than once.

The format specifiers are C for the currency symbol, H for the narrow
currency symbol, I for the ISO 4217 currency code, N for the currency name
and G for the amount alone, each optionally followed by a precision. A
format string consisting of a currency code emits the code and asserts that
it is the currency of the value, so that a mismatch is reported rather than
formatted. C follows .Net's convention of denoting the currency symbol,
which changes the output of the previously supported c and C formats: the
currency code is now obtained with I.

The culture supplies presentation only -- separators, group sizes, the
placement of the currency token and the sign -- while the currency denoted
and the default precision come from the value. Formatting a New Zealand
Dollar value under an Indian culture therefore groups the amount in the
Indian style but continues to denote New Zealand Dollars, rather than
asserting that the amount is denominated in Rupees. A value with no
currency emits no currency token at all.

Add the satellite resources for sixteen cultures, without which every
culture resolved to the invariant presentation.

Grouping and rounding are delegated to decimal so that they match the
intrinsic numeric types, including variable group sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parsing previously accepted an ISO 4217 currency code only. It now also
accepts a currency symbol, but only where the currency that symbol denotes
can be established with certainty.

Most symbols denote exactly one currency and are resolved without any
culture at all. The few that are shared -- the dollar sign alone is used by
some thirty currencies -- are resolved from the currency of the culture
being parsed for, and only when the caller has asked for that by including
AllowAmbiguousCurrencySymbol. Where the currency cannot be determined,
parsing fails: choosing one on the value's behalf would silently misstate
the amount. A symbol is never resolved from the culture of the current
thread, which says nothing about the origin of a value being parsed.

Add MoneyStyles, which lets a caller accept only the elements they expect,
as NumberStyles does for the intrinsic numeric types. A caller reading a
feed of currency code values can reject anything carrying a symbol.

Add ParseExact and TryParseExact, which accept a value only where formatting
the result reproduces it under one of the expected formats. This keeps the
accepted forms in step with those the formatter produces, and gives the
round trip its strongest form: a value formatted with the currency code
form parses back only from that form.

Fix MoneyInfo.CurrentMoney, which threw when first read because it
interpreted the language and region subtags of the current culture with
Enum.TryParse, and neither is a System.Enum. It is now derived from the
culture directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declare IParsable, ISpanParsable and ISpanFormattable on the targets which
define them, so that Money may be used by generic code written against
those interfaces as the intrinsic numeric types are.

Route the existing Parse and TryParse overloads through the parser added
for symbol recognition, and remove the pattern machinery they used. That
machinery matched an amount which admitted no group separators, so a value
such as NZD1,234.50 parsed as 1234, silently discarding the minor units
whenever a group separator was present. It also rebuilt an alternation of
every currency code on each call.

The behaviour of the existing overloads is otherwise unchanged: they
continue to accept a currency code, and now also accept a currency symbol
which denotes exactly one currency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cover the formatter across its specifiers, precision handling, culture
patterns and fallback behaviour; the parser across the three ways a
currency is identified; and the monetary format information across its
construction, culture tracking and read-only semantics.

Two tests are worth calling out because they pin decisions rather than
mechanics. The first asserts that a value of one currency formatted under
the culture of another applies that culture's grouping but continues to
denote its own currency, so that a New Zealand Dollar value shown to an
Indian reader is grouped in the Indian style yet is never rendered as
Rupees. The second asserts that formatting a monetary value with an
unrecognised format does not recurse: a monetary value is itself
formattable, so resolving such a format through the formattable argument
would resolve the formatter again and would not terminate.

The parsing tests assert the negative cases as deliberately as the positive
ones: a shared currency symbol must fail to parse without a culture to
resolve it, and must fail again where the culture supplied does not account
for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A package whose culture-specific resources are absent does not fail to
load. Every culture silently falls back to the neutral resources, so
monetary values are formatted with the invariant separators and patterns
wherever the package is consumed, which is a difficult failure to attribute
after the fact.

Packing was verified to carry the satellite assemblies through, but the
pack step runs against previously built artifacts rather than a fresh
build, so their presence is now confirmed rather than assumed. The check
was tested against a package with the satellite assemblies removed to
establish that it does in fact fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add how-to guides for formatting and parsing, and reference pages for the
types which had none: CurrencyFormatInfo, MoneyInfo, MoneyStyles,
CurrencySymbolKind and MissingCultureResourceException.

Both the how-to guide and the Money reference page call out the changed
meaning of the C specifier, which now emits the currency symbol rather than
the currency code, and direct readers to I for the code.

Record the distinction between what the culture governs and what the value
governs, since it is the question most likely to be asked of this design: a
value of one currency formatted under the culture of another is grouped in
that culture's style but continues to denote its own currency.

Add the Unicode copyright notice required by the licence under which the
currency symbol data is published, in a third-party notices file and in the
header of the generated source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TryParseFormat raised a FormatException for a format string which named the
wrong currency or an invalid precision. A method offering to try an action
must report failure by its return value; raising an exception defeats the
purpose of being able to attempt the action at all.

The method now returns the outcome of interpreting the format string, and
is named accordingly. Format raises the exception, which is where the
decision belongs: it makes no undertaking to be free of exceptions, and it
is the method the caller asked to format the value.

The distinction between an unrecognised format string and an invalid one is
now explicit in the outcome rather than implied by control flow. The former
may still be a custom numeric format string, while the latter must not be
reinterpreted as one, because handing a currency specifier to the number
formatter would emit the culture's own currency symbol in place of that of
the value.

Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing them

The conditional compilation directives named .NET 8.0, but ISpanFormattable
has been defined since .NET 6.0 and IParsable and ISpanParsable since
.NET 7.0. The earliest version covering all three is .NET 7.0, so naming a
later one withheld the interfaces from targets which support them.

Update the documentation to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant