Problem
Saving a tariff price with a comma as decimal separator, e.g. 0,49 from a German mobile keyboard, fails with HTTP 500 on PATCH /v1/pricing-groups/:id/tariffs/:tariffId (and on POST .../tariffs). Only 0.49 can be saved.
Two causes:
- API:
nonNegativePrice and taxRate in routes/pricing.ts validate with .refine(). zod-to-json-schema strips refines when zodSchema() builds the JSON Schema that Fastify validates against, so "0,49" passes validation and Postgres rejects it for the numeric column. The same limitation is already worked around in carbon.ts and notifications.ts.
- CSMS: tariff prices and station coordinates are plain text inputs whose value is sent verbatim. The other decimal fields use
<input type="number">, whose accepted separator follows the browser locale instead of the selected UI language. Input the browser cannot parse is reported as an empty value, which the tariff form would send as null and silently clear the price.
i18next and Intl only format numbers; there is no standard parser for localized number input.
Proposed fix (PR follows)
- API: express the price and tax rate bounds as
.regex() patterns, which end up as pattern in the JSON Schema and are enforced by Ajv. "0,49" then returns 400 VALIDATION_ERROR. The API keeps . as its only decimal separator.
- CSMS: a
DecimalInput component that shows the decimal separator of i18n.language, accepts . and , when typing or pasting, and always hands the canonical string ("0.49") to the form. Used for all decimal fields. Thousands separators are intentionally not supported so that 1.000 is never misread; pasted text with several separators is ignored rather than guessed.
New dependency
DecimalInput builds on react-number-format (MIT, v5.4.5, peer dependency React 19 supported). It handles caret position, pasting, sign and decimal scale, and keeps the value as a string, so money amounts are not converted to floating point. Alternatives considered: react-aria NumberField (thorough locale parsing, but float-based and a second component model next to Radix/shadcn) and a hand-written parser. Happy to adjust if you prefer another approach.
Related
About 20 other .refine() calls in request schemas are also not enforced for the same reason, e.g. latitude/longitude ranges in sites.ts and checks in panels.ts and smart-charging.ts. They are out of scope here. Switching validation to fastify-type-provider-zod (already listed in packages/api/package.json, currently unused) would make all refines effective, but touches every route and the OpenAPI generation.
Problem
Saving a tariff price with a comma as decimal separator, e.g.
0,49from a German mobile keyboard, fails with HTTP 500 onPATCH /v1/pricing-groups/:id/tariffs/:tariffId(and onPOST .../tariffs). Only0.49can be saved.Two causes:
nonNegativePriceandtaxRateinroutes/pricing.tsvalidate with.refine().zod-to-json-schemastrips refines whenzodSchema()builds the JSON Schema that Fastify validates against, so"0,49"passes validation and Postgres rejects it for thenumericcolumn. The same limitation is already worked around incarbon.tsandnotifications.ts.<input type="number">, whose accepted separator follows the browser locale instead of the selected UI language. Input the browser cannot parse is reported as an empty value, which the tariff form would send asnulland silently clear the price.i18next and
Intlonly format numbers; there is no standard parser for localized number input.Proposed fix (PR follows)
.regex()patterns, which end up aspatternin the JSON Schema and are enforced by Ajv."0,49"then returns 400VALIDATION_ERROR. The API keeps.as its only decimal separator.DecimalInputcomponent that shows the decimal separator ofi18n.language, accepts.and,when typing or pasting, and always hands the canonical string ("0.49") to the form. Used for all decimal fields. Thousands separators are intentionally not supported so that1.000is never misread; pasted text with several separators is ignored rather than guessed.New dependency
DecimalInputbuilds on react-number-format (MIT, v5.4.5, peer dependency React 19 supported). It handles caret position, pasting, sign and decimal scale, and keeps the value as a string, so money amounts are not converted to floating point. Alternatives considered: react-ariaNumberField(thorough locale parsing, but float-based and a second component model next to Radix/shadcn) and a hand-written parser. Happy to adjust if you prefer another approach.Related
About 20 other
.refine()calls in request schemas are also not enforced for the same reason, e.g. latitude/longitude ranges insites.tsand checks inpanels.tsandsmart-charging.ts. They are out of scope here. Switching validation tofastify-type-provider-zod(already listed inpackages/api/package.json, currently unused) would make all refines effective, but touches every route and the OpenAPI generation.