docs/plans/5.5-client-side-money-normalization.md
Status: In Progress
Target: Spree 5.5
Depends on: 5.4-store-api-naming-standardization.md (monetary string convention)
Author: Damian + Claude
Last updated: 2026-06-16
The React dashboard and Admin API should follow the industry-standard model for monetary amounts: clients send a canonical decimal string ("1234.56", period decimal, no grouping); localization is a presentation-layer concern only. The dashboard parses the merchant's locale-formatted input ("1.234,56") into canonical form in the browser before it reaches the API. No request locale (x-spree-locale) is needed to parse money.
This replaces the in-progress approach where the dashboard sent the raw localized string and relied on the server's Spree::LocalizedNumber.parse (keyed off the request locale) to decode it. That approach is non-standard, couples money entry to an x-spree-locale header the SDK had to send, and breaks down where one request carries amounts in multiple currencies (the product PATCH batches USD + EUR prices — a single locale can't parse both).
Spree::LocalizedNumber.parse stays in the models, unchanged. The legacy Rails admin keeps relying on it (its inputs are locale-formatted and parsed server-side under the session locale). A canonical "19.99" parses correctly under any locale, so a dashboard that always sends canonical strings needs no server change — the pivot is client-side only.
Every major commerce platform treats the money API format as canonical and locale-agnostic; localized parsing happens client-side or not at all:
| Platform | API money format | Localized input parsing |
|---|---|---|
| Shopify | Decimal scalar string, always "19.99" (period, no grouping), independent of admin locale (MoneyInput.amount: Decimal!) | Display only (Intl.NumberFormat / Liquid filters) |
| Saleor | Money { amount: Float!, currency } | Display only — docs: "use the internationalization API provided by the browser" |
| Vendure | Integer minor units (cents); 12345 = 123.45 | Client-side — admin CurrencyInputComponent "displays currency in decimal format, whilst working with the integer cent value in the background" |
| Medusa | Integer minor units (cents) for API I/O | Client-side / N/A (integers have no separator ambiguity) |
None parse comma-vs-period strings on the server. Spree's x-spree-locale-driven server parse is the outlier.
There are two unrelated "locales" in the dashboard, and keeping them separate is what makes this design correct:
| Controls | Lives in | Sent to the API? | |
|---|---|---|---|
| Dashboard UI language | The chrome — buttons, labels, toasts ("Save", "Issue store credit") | React SPA state (i18next i18n.language) | Never |
| Store content locale | The data — product names, descriptions; the canonical source of truth | Server (store's main/canonical locale) | Dashboard always uses canonical |
Motivating scenario: a back-office worker in a cheaper country operates a US store. They set the dashboard to German UI so they can read the interface — but every product, price, and order they manage is still the store's canonical US/English/USD data. The German-UI preference is "what language are my buttons in," not "show me German product data."
Consequences (binding):
x-spree-locale header. If it leaked into the API it would (a) silently scope every read to translations the store may not have, and (b) re-break canonical money parsing ("19.99" under de → 1999).@spree/admin-sdk has NO client-level locale/currency switching (unlike the Store SDK's setLocale/setCurrency/setCountry). The dashboard is a back-office tool operating in the canonical locale; a global locale default is wrong for it and a money footgun.options.locale / options.currency on a single call. This already works via the generic RequestOptions path (no client-level state). Examples: a translations editor writing a German product name ({ locale: 'de' } on that update); a currency-scoped price read ({ currency: 'EUR' } on that GET)."19.99" — period decimal, no thousands separator, matching what serializers already emit (see docs/api-reference/admin-api/monetary-amounts.mdx). This is already the read format; this plan makes it the explicit write format too."1234.56". Built on Intl.NumberFormat(...).formatToParts to derive the locale's separators, then strip grouping and standardize the decimal to ..x-spree-locale is sent to drive money parsing. Revert the per-form locale overrides AND the client-level admin-sdk locale support added on the feature branch. The admin-sdk has no setLocale/setCurrency/AdminClientConfig.locale (see "Two independent locales" above) — deliberate per-locale/per-currency calls use the generic per-request options.locale/options.currency.Spree::LocalizedNumber.parse is NOT removed and NOT changed. It remains the parser for the legacy Rails admin and any other locale-formatted caller. Because canonical "19.99" is locale-invariant, no model/controller change is required for the dashboard pivot.spree/admin) is out of scope. It keeps submitting locale-formatted values parsed server-side under the session locale. Untouched.LocalizedNumber-guarded setter (amounts are derived server-side; Spree::Payment#amount= has its own parser). No change needed.The model money setters (Spree::Price#amount=, Spree::StoreCredit#amount=, etc. — ~11 of them) stay unchanged: self[:amount] = Spree::LocalizedNumber.parse(amount). This is safe but for a subtle, must-understand reason:
LocalizedNumber.parse is NOT locale-invariant. A canonical "1234.56" parses correctly only under a period-decimal locale:
| Input | en parse | de / fr parse |
|---|---|---|
"1234.56" | 1234.56 ✅ | 123456 ❌ (. treated as thousands separator) |
"19.99" | 19.99 ✅ | 1999 ❌ |
The pivot is safe in the common case: the admin-sdk sends no x-spree-country and no x-spree-locale, so LocaleAndCurrency never resolves a comma-decimal market and Spree::Current.locale falls back to the store's default_locale. For the default and seeded stores that is en (a period-decimal locale), under which canonical input round-trips correctly — making the setters an effective no-op for the dashboard while still serving the Rails admin.
This is a known constraint, not a guarantee. It rests on the store's default locale being period-decimal. A store whose default_locale is a comma-decimal locale (e.g. de) would have the model setters re-parse canonical "19.99" as 1999. Likewise if a dashboard request ever resolves to a comma-decimal locale (someone sends x-spree-locale: de, or admin requests start resolving a market from x-spree-country). The 6.0 fix below removes the constraint entirely by making the Admin API contract locale-invariant; until then it is guarded by the e2e money tests (which assert canonical persistence) and should be treated as a temporary assumption.
Keep LocalizedNumber.parse in 5.5 for backward compatibility (the Rails admin needs it; it's harmless for the dashboard under en). In 6.0, make localized parsing a presentation-layer concern only:
^-?\d+(\.\d+)?$). Admin API controllers/models assign canonical values directly (e.g. BigDecimal(value)), not via LocalizedNumber.parse.LocalizedNumber.parse survives only where HTML-form params arrive locale-formatted — i.e. the legacy Rails admin — and ideally is scoped to that layer rather than living on the model setter.This matches the industry research (Shopify/Saleor canonical decimal; Vendure/Medusa minor-unit integers — none parse comma-vs-period server-side).
A single util in @spree/dashboard (or dashboard-ui), e.g. normalizeMoneyInput(raw: string, locale: string): string:
Intl.NumberFormat(locale, { useGrouping: true }).formatToParts(11111.1).., drop any non-[0-9.\-].''/null for blank). No Number() round-trip — preserve full precision as a string.The display locale per currency is resolved from the store's markets (useCurrencyLocale, already built): a EUR cell formats/reads under the EUR market's de locale, so the merchant sees and types 1.234,56, and the util converts to 1234.56 on the way out.
Each money form: keep locale-aware display (already done via currencyParts / market locale), add a normalize-on-submit step:
customers/$customerId.tsx, Issue + Edit dialogs): normalize amount before mutateAsync. Drop the { locale } override.bulk-price-editor.tsx): normalize each cell's amount/compare-at before bulkUpsert. Drop the { locale } override. (Display already uses the market locale.)product-bulk-price-editor.tsx → product PATCH): normalize each variants[].prices[] amount before the product update. This unblocks the multi-currency product form — every amount is canonical regardless of currency, so the single batched PATCH is fine and needs no locale.packages/dashboard/src/hooks/use-customer-store-credits.ts — { params, locale } → back to params, no locale.bulk-price-editor.tsx / use-prices.ts — drop the locale plumbing on upsert.customers/$customerId.tsx — drop localeForCurrency(...) override args (keep the currency→locale resolver for display).packages/dashboard-core/src/lib/i18n.ts — remove the adminClient.setLocale(i18n.language) money-motivated wiring (or keep purely for translated-content locale, decided separately — not for money).@spree/admin-sdk is UNCHANGED. The client-level setLocale/setCurrency/AdminClientConfig.locale briefly added on the branch was fully reverted — the admin-sdk needs no change for this work. Per-request options.locale/options.currency already exist on the generic RequestOptions path (untouched) for the rare deliberate translation-write / currency-scoped-read.global-setup.ts + the markets/price-lists spec deconfliction — needed for multi-currency tests regardless of approach.spree_i18n added to spree/api/Gemfile — the test app should match production, which bundles it (and the Rails admin needs it). Note: with the pivot, the dashboard money path no longer depends on it (it resolves to en); it's retained for production parity and the Rails admin.The dashboard's own UI language (i18next i18n.language) is unaffected by this plan and stays a pure SPA concern — a back-office worker can run a US store with a German interface. It is never forwarded to the API (the adminClient.setLocale(i18n.language) wiring briefly added on the branch was reverted). If/when more dashboard UI locales ship, the language switcher mutates i18n.language only.
monetary-amounts.mdx to state the write format explicitly; remove the "localized string accepted" framing for the Admin API / dashboard path).normalizeMoneyInput util + tests in the dashboard.x-spree-locale overrides.1.234,56 / 55,55, assert canonical persistence — the value is normalized client-side, so the assertions already written (round-trip + mangle guard) hold without the server-locale dependency.Spree::LocalizedNumber and the Rails admin untouched."1234.56".x-spree-locale-for-money plumbing. The header is not the money mechanism.Spree::LocalizedNumber.parse — the Rails admin still needs it."19.99".dashboard-ui (so the MoneyCell owns it end-to-end) or dashboard (form layer)? Leaning dashboard-ui since the cell already owns display formatting.^\d+(\.\d+)?$ validation) to make the contract explicit, or keep tolerating localized input server-side (via the models' LocalizedNumber) as a lenient fallback? Strict is more Shopify-like; lenient is backward-compatible. Recommend lenient now, revisit.docs/api-reference/admin-api/monetary-amounts.mdx — current money-format docsdocs/api-reference/store-api/monetary-amounts.mdx — Store API (read-only) money formatSpree::LocalizedNumber — spree/core/lib/spree/localized_number.rb (kept for Rails admin)chore/dashboard-e2e-monetary-forms — admin-sdk locale support, EUR market seed, the x-spree-locale money approach this plan supersedes