Skip to content

Implement formatMoney template function - #88

Open
drubtsov1 wants to merge 14 commits into
masterfrom
rubtsov/format-money-function
Open

Implement formatMoney template function#88
drubtsov1 wants to merge 14 commits into
masterfrom
rubtsov/format-money-function

Conversation

@drubtsov1

@drubtsov1 drubtsov1 commented Aug 12, 2026

Copy link
Copy Markdown

Adds formatMoney(amount, currencyCode) and formatMoney(amount, currencyCode, displayMode) to the standard function library.

Currency data comes from NodaMoney. It deliberately does not go through RenderSettings.CultureInfo the way formatDecimal does: the currency belongs to the order, not to the project, so project culture is the wrong input here.

Display modes:

Mode Output
narrowSymbol (default) $1,234.56, Ft 3,075.00
symbol US$1,234.56, S$1,234.56
code USD 1,234.56

Decimal places follow ISO 4217 minor units, so JPY renders without decimals and KWD with three. This matches what Shopify shows a shopper at checkout, verified against live checkouts in nine currencies.

An unrecognised currency code falls back to the amount followed by the uppercased code, so a template never renders empty. Separators are plain spaces, keeping SMS inside the GSM alphabet.

drubtsov1 and others added 14 commits August 12, 2026 17:42
Format an amount together with its currency code, so a template can show
the price a customer actually paid without the author hard-coding a
currency sign.

    ${ formatMoney(Order.CustomField.Total, Order.CustomField.Currency) }

Two overloads by arity, the way substring is done: the two-argument form
uses the narrow symbol, the three-argument form takes a display mode of
narrowSymbol, symbol or code. An unsupported constant mode is a static
template error; a mode arriving through the model falls back to
narrowSymbol rather than failing the render.

Symbols, symbol placement, digit grouping and decimal places come from a
table rather than from RenderSettings.CultureInfo. The currency is a
property of the order while the culture is a property of the project, so
deriving the format from the render culture is what makes a shop's
emails show the wrong money today. .NET also exposes no way to map an
ISO 4217 code to a symbol without enumerating cultures, which is neither
stable across ICU versions nor available under invariant globalization.

Grouping follows the currency, since it is the only signal about the
reader: $1,234.56 but 1.234,56 € and 12,34,567.89 for the Indian lakh
grouping. Separators are plain spaces, not U+00A0, because the same
engine renders SMS and a non-breaking space forces UCS-2, halving the
segment length.

An unknown or absent currency code renders the amount followed by the
code as written, so an unmapped currency degrades instead of taking the
whole message down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review pass over the currency table against CLDR, plus the edge cases a
money formatter meets in production.

Data: SGD's disambiguated symbol was invented (CLDR has no "S$"), ILS
follows the amount in its own locale, and ARS/BRL/COP take a space after
the symbol that the letter heuristic cannot express. Currencies outside
the table now take their minor units from ISO 4217 instead of assuming
two, so PKR no longer grows centimes and TND no longer loses a digit.

Negative amounts put the sign before the symbol rather than between it
and the digits, and an amount that rounds to zero is no longer rendered
as a negative zero. Display mode is matched case-insensitively, like the
currency code already was, and a null mode falls back like an
unsupported one instead of failing the render. A null amount renders as
zero: these fields are legitimately absent on orders without shipping or
tax, and taking the message down over one of them is worse than showing
zero.

The number formats are precomputed per currency and made read-only, so
the shared instances cannot be mutated by a later edit and each call
stops allocating its format string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eleven currencies carried a non-default grouping style that no test
touched, so their assignment could be changed without failing anything.
Adds them at an amount large enough to show two group separators, which
also pins their symbol placement.

Display-mode casing is now asserted per mode rather than by inequality,
and the round-to-zero rule is checked on a zero-decimal currency too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hand-written table held 42 currencies against the 161 Shopify
accepts, so a merchant selling in naira or Pakistani rupees got the bare
ISO code and, worse, invented decimals. The table is now generated from
CLDR for all 152 usable Shopify codes, which also removes the
hand-curation that produced an "S$" symbol CLDR has never had.

Generating from one locale collapsed most of the model. In CLDR's
en-001 every currency puts the symbol first and groups in thousands with
a dot decimal, so per-currency grouping styles, symbol placement and the
Indian lakh special case all disappear; what actually varies is the
symbol, the number of decimals and whether a space follows the symbol.

This also settles a question the previous table answered by accident.
Formatting now follows the language the email is written in rather than
each currency's home locale, so a Vietnamese amount reads as
"₫1,731,200" in an English message instead of "1.731.200 ₫". That
matches the acceptance examples in the effort card, which were written
that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The table was written by hand, which is how it ended up claiming an "S$"
symbol that exists in no CLDR locale and giving the forint two decimal
places it lost in 1999. It is now generated from the Unicode CLDR data
packages, pinned by version in tools/currency-table.

The generated file stays committed so builds need no network and every
data change arrives as a reviewable diff. CI re-runs the generator and
fails if the committed file differs, so the table can neither be edited
by hand nor drift from CLDR unnoticed; refreshing after a CLDR release
is a version bump and a rerun.

Taking the data straight from CLDR also fixes what the hand-written
version got wrong: fourteen currencies, the forint and the Pakistani
rupee among them, no longer show a fractional part they do not have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generator removed the hand-maintenance of the table itself, but the
pinned CLDR version still had to be bumped by someone remembering to.
Dependabot now does it monthly, and the pin stays because it is what
makes the CI regeneration check deterministic and what keeps a CLDR
release — which moves the money shown in every tenant's emails — from
arriving inside an unrelated pull request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renovate is the standard here, not Dependabot, and this repository has
neither — Renovate was offered in 2020 and the pull request was closed.
Turning on a dependency bot is the maintainers' call, not something to
slip into a pull request about formatting money. The CLDR bump stays
manual for now; the CI check is what keeps the table from drifting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generator took the currency table off hand-maintenance, but the
pinned CLDR version still needed a human to remember it. Renovate is the
standard across the organisation and already has a shared preset here,
which sibling packages such as Mindbox.Analyzers and data-linq extend.

This repository's onboarding pull request was closed in 2020, but that
was not a lasting decision: data-linq closed the same evening's
invitation and later opted in through a config file, which is the path
Renovate's own bot documented in the closed pull request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generator was fed a checked-in list of the currency codes Shopify
accepts, which quietly reintroduced the hand-maintenance the generator
removed: a currency added by any payment provider would be absent from
the table and render without a symbol and with two decimal places it may
not have.

The list is gone. All 307 currencies CLDR carries are generated, which
costs twelve kilobytes of source and covers anything ISO 4217 defines,
including currencies that do not exist yet. It also fits the engine
better — it renders templates for every integration, and had no business
knowing which currencies one of them happens to support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The workflow pointed at tools/currency-table while git records the
directory as Tools/currency-table, next to the existing Tools/ holding
the ANTLR jar. A case-insensitive macOS filesystem hid this locally and
the step failed on Linux before anything else could run, which is also
why the test report found no results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lockfile was generated on a machine configured against the internal
Artifactory mirror, so its resolved URLs pointed there and CI could not
authenticate. The tool now pins the public registry and the lockfile is
regenerated against it, which is also the right source for an
Apache-licensed package built on a public runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Installing the packages pulled forty megabytes of every locale on earth
to read one hundred and forty kilobytes, and brought a lockfile, a
registry and an authentication failure with it — the lockfile was
resolved against the internal Artifactory mirror, which a public runner
cannot reach.

The two files are now copied verbatim from the CLDR release named in
cldr/VERSION, the generator needs nothing but Node, and the build no
longer touches the network. Refreshing is two curl commands documented
next to the script.

The Renovate config goes with it: it was added to bump the CLDR pin, and
there is no longer a pin to bump. Whether this repository wants a
dependency bot for its other packages is a decision for its maintainers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mode arrives as a string because the template language has no other
way to spell it, and because it may come from the model rather than a
literal. That is fine at the boundary; carrying the string into the
formatter was not. Every call compared it up to three times, and the set
of valid modes was spelled out twice — once in the validator and once in
the formatter — which is two places to disagree.

It is now parsed at the boundary into an enum, and the validator asks
the same parser the formatter uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hand-rolled table was built from CLDR because I believed no library
carried the disambiguated symbols the card asks for. That was wrong: I
had only read NodaMoney's Symbol property and missed InternationalSymbol
sitting next to it, which yields US$, S$, CA$ and NT$ directly. CLDR
cannot produce S$ at all — it falls back to the SGD code — so the table
failed the card's own acceptance example while the library meets it.

Decimals move from CLDR's display digits to ISO 4217 minor units. That
gives HUF, IDR, PKR and COP two decimals rather than none, which matches
what Shopify shows: its checkout always renders decimal places, and it
does not list those currencies as zero-decimal.

Arabic-script currencies now render their native RTL symbol where the
table substituted the ISO code. Left as is on purpose — a rule guarding
them would have to test for RTL script, and the obvious spelling of it
("symbol is not Latin") would also catch the won, baht and rupee. The
display mode already lets a template ask for the code instead.

Drops the generator, the vendored CLDR payload and the CI drift check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Mindbox.Quokka.Abstractions 95% 92% 87
Mindbox.Quokka 83% 79% 2220
Summary 83% (4890 / 5858) 79% (940 / 1191) 2307

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