Back to Spree

Client-Side Monetary Normalization (Admin API + React Dashboard)

docs/plans/5.5-client-side-money-normalization.md

5.5.015.1 KB
Original Source

Client-Side Monetary Normalization (Admin API + React Dashboard)

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

Summary

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.

Why (industry research)

Every major commerce platform treats the money API format as canonical and locale-agnostic; localized parsing happens client-side or not at all:

PlatformAPI money formatLocalized input parsing
ShopifyDecimal scalar string, always "19.99" (period, no grouping), independent of admin locale (MoneyInput.amount: Decimal!)Display only (Intl.NumberFormat / Liquid filters)
SaleorMoney { amount: Float!, currency }Display only — docs: "use the internationalization API provided by the browser"
VendureInteger minor units (cents); 12345 = 123.45Client-side — admin CurrencyInputComponent "displays currency in decimal format, whilst working with the integer cent value in the background"
MedusaInteger minor units (cents) for API I/OClient-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.

Two independent locales — do not conflate (drives the SDK decision)

There are two unrelated "locales" in the dashboard, and keeping them separate is what makes this design correct:

ControlsLives inSent to the API?
Dashboard UI languageThe chrome — buttons, labels, toasts ("Save", "Issue store credit")React SPA state (i18next i18n.language)Never
Store content localeThe data — product names, descriptions; the canonical source of truthServer (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):

  • The dashboard UI language is i18next-only SPA state and must NEVER become an 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).
  • The @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.
  • Deliberate per-locale/per-currency needs use a per-request overrideoptions.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).

Key Decisions (do not deviate without discussion)

  • Admin API money contract is canonical decimal strings. Read and write: "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.
  • The React dashboard normalizes localized input → canonical before sending. A shared util parses the merchant's input in the active display locale (currency's market locale) and emits "1234.56". Built on Intl.NumberFormat(...).formatToParts to derive the locale's separators, then strip grouping and standardize the decimal to ..
  • No 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.
  • Rails admin (legacy spree/admin) is out of scope. It keeps submitting locale-formatted values parsed server-side under the session locale. Untouched.
  • Store API is out of scope. Audited: the Store API v3 accepts no client-supplied amount that reaches a LocalizedNumber-guarded setter (amounts are derived server-side; Spree::Payment#amount= has its own parser). No change needed.

Why this is safe with the existing model setters (important)

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:

Inputen parsede / 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.

Deprecation path (6.0)

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:

  • The Admin API money contract is strictly canonical (^-?\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.
  • Deprecate the locale-parsing model setters: warn when they receive a non-canonical string, then drop the parse in 6.0.

This matches the industry research (Shopify/Saleor canonical decimal; Vendure/Medusa minor-unit integers — none parse comma-vs-period server-side).

Design Details

The normalization utility (dashboard)

A single util in @spree/dashboard (or dashboard-ui), e.g. normalizeMoneyInput(raw: string, locale: string): string:

  1. Derive the locale's decimal + group separators via Intl.NumberFormat(locale, { useGrouping: true }).formatToParts(11111.1).
  2. Strip the group separator, replace the locale decimal separator with ., drop any non-[0-9.\-].
  3. Return the canonical string (or ''/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.

Money form integration points (dashboard)

Each money form: keep locale-aware display (already done via currencyParts / market locale), add a normalize-on-submit step:

  • Store credit (customers/$customerId.tsx, Issue + Edit dialogs): normalize amount before mutateAsync. Drop the { locale } override.
  • Price-list bulk editor (bulk-price-editor.tsx): normalize each cell's amount/compare-at before bulkUpsert. Drop the { locale } override. (Display already uses the market locale.)
  • Product base prices (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.
  • Gift cards, payments, refunds, store-credit elsewhere: same pattern where a money input exists.

What gets reverted from the feature branch

  • 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).

What stays from the feature branch

  • @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.
  • EUR market seed in e2e 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.

Dashboard UI language (out of scope, but adjacent)

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.

Migration Path

  1. Land the canonical-string contract: confirm Admin API serializers emit canonical strings (done) and document that writes expect the same (update monetary-amounts.mdx to state the write format explicitly; remove the "localized string accepted" framing for the Admin API / dashboard path).
  2. Add normalizeMoneyInput util + tests in the dashboard.
  3. Wire it into each money form's submit path; revert the per-form x-spree-locale overrides.
  4. Update E2E tests: enter localized 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.
  5. Leave Spree::LocalizedNumber and the Rails admin untouched.

Constraints on Current Work

  • New dashboard money inputs must normalize on submit — never send a locale-formatted string to the Admin API; send canonical "1234.56".
  • Do not add new x-spree-locale-for-money plumbing. The header is not the money mechanism.
  • Do not remove or weaken Spree::LocalizedNumber.parse — the Rails admin still needs it.
  • Admin API money writes are canonical decimal strings — keep read/write symmetry on "19.99".

Open Questions

  • Should the normalize util live in dashboard-ui (so the MoneyCell owns it end-to-end) or dashboard (form layer)? Leaning dashboard-ui since the cell already owns display formatting.
  • Long-term: do we want the Admin API to reject non-canonical money strings (strict ^\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.

References