Skip to content

fix(clients): locale-aware thousands/decimal separator parsing in MoneyInput - #14190

Closed
detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-clients-locale-aware-thousands-decimal-separat-cc59f8
Closed

detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-clients-locale-aware-thousands-decimal-separat-cc59f8

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Summary

Related Issue: polarsource/feedback#456

MoneyInput misparsed US thousands separators (commas) as decimal separators for en-locale users, collapsing whole amounts ~1000× (e.g. 5,0005.00, submitting 500 cents instead of 500000). This affected buyer-facing Pay-What-You-Want checkout and every seller-facing pricing form, with no client or server guard catching the magnitude error.

What

  • Added two shared, pure helpers to @polar-sh/currency:
    • getLocaleDecimalSeparator(locale) — returns the locale's decimal separator (. or ,) via Intl.NumberFormat.
    • parseMoneyValue(input, decimalSeparator) — locale-aware parser that normalizes a typed/pasted string to a .-decimal display value, stripping thousands separators and rounding to 2 fractional digits.
  • Replaced the buggy regex-based onChange parser in both MoneyInput implementations (packages/checkout and packages/ui) with parseMoneyValue, and added an optional locale prop (default 'en') so parsing branches on the locale's decimal separator.
  • Threaded the existing locale prop from CheckoutPWYWForm into its MoneyInput.
  • Made the onKeyDown multi-separator guard locale-aware.
  • Added unit tests for the parser in the currency package and regression + locale tests in CheckoutPWYWForm.test.tsx.

Why

The previous onChange ran cleaned.match(/([.,])([0-9]+)$) against the un-stripped input, so any trailing separator+digits was unconditionally treated as a decimal — regardless of locale. For en users, 5,000 matched ,000 and was rewritten to 5.00. The locale prop was only used for placeholder formatting, never for parsing. A correct fix must branch on locale: commas are thousands separators for en/ja/ko, but decimal separators for de/fr/etc. The deduped shared parser fixes both MoneyInput copies (checkout + @polar-sh/ui) in one place; the web-dashboard call sites gain the en fix with no call-site changes since locale defaults to 'en'.

How

  • The parser prefers the locale's own decimal separator when present. For comma-decimal locales, a lone period (the toFixed display format of committed values) is also treated as a decimal so editing round-trips correctly. For period-decimal locales, a lone comma is always a thousands separator — the reported bug. A trailing separator with no fractional digits (e.g. 5.) is preserved during typing and stripped on blur. At most one decimal separator is honored (the last); earlier occurrences are stripped from the integer part.
  • Both MoneyInput implementations compute decimalSeparator = getLocaleDecimalSeparator(locale) and call parseMoneyValue(input, decimalSeparator) in onChange. The getUnits (minor-units conversion) and onBlur handlers are unchanged.

Testing

  • Unit tests (@polar-sh/currency): 73 tests covering getLocaleDecimalSeparator for all 14 supported locales and parseMoneyValue across the period-decimal and comma-decimal matrices (thousands stripping, mixed thousands+decimal, trailing-separator typing, 2-digit rounding, leading-zero guard, junk stripping, empty input). All pass.
  • Integration tests (CheckoutPWYWForm.test.tsx): 20 tests — the 9 existing baseline cases plus en thousands-separator regression cases (5,000→500000, 1,234→123400, 12,345→1234500, 1,000,000→100000000), en mixed (5,000.99→500099), en period rounding (5.55→555), de comma-decimal cases (12,5→1250, 12,50→1250, 12,500→1250, 1.234,56→123456), and a regression case verifying 1,234 no longer surfaces a misleading "below minimum" error. All pass.
  • Full package suites: @polar-sh/currency 73/73 and @polar-sh/checkout 451/451 (34 files). All pass.
  • Typecheck, format, and lint pass for currency, checkout, ui, and web (tsc --noEmit, oxfmt --check, oxlint .).
  • Full client build passes (turbo, 13/13).
  • End-to-end browser verification (Playwright via the verifier-web skill) could not be run: the in-container web frontend hit a host cgroup v2 runtime error, the host next dev process doesn't survive the shell session, and Playwright is not installed in this environment. The infra + API stack was brought up and confirmed serving (/healthz{"status":"ok"}). The behavioral coverage rests on the unit and integration suites, which verify the exact minor units reaching the API through the form's debounce path for both en and de locales.

Checklist

  • This PR addresses a single concern (one bug fix)
  • The diff is reasonably sized and easy to review
  • New functionality is covered by tests
  • Linting and type checking pass
  • No unrelated changes or drive-by fixes are included

Automatic Fixes PRs can be configured here.

Review in cubic

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
orbit Ready Ready Preview Sep 7, 2026 1:32am UTC
polar-test Ready Ready Preview Sep 7, 2026 1:32am UTC

Request Review

This branch was successfully deployed

2 active deployments
Preview – polar-test f8d1886a Deployed Sep 7, 2026 by vercel[bot]
Preview – orbit f8d1886a Deployed Sep 7, 2026 by vercel[bot]
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