Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Currency formatting overrides each currency's own fraction-digit convention: JPY renders ¥1,234.50, KWD renders KWD 1.50 #4361

Closed

Description

@yinlianghui

Summary

Both currency formatting paths in packages/fields hardcode a fraction-digit width and pass it to Intl.NumberFormat, which overrides the digit count ISO 4217 defines for the currency being rendered. Intl knows that JPY has 0 decimal places and KWD has 3; giving it explicit minimumFractionDigits / maximumFractionDigits throws that knowledge away.

Measured directly against the construction (node 22, full ICU):

JPY 1234.5   Intl default          ¥1,235
JPY 1234.5   as we render it       ¥1,234.50
KWD 1.5      Intl default          KWD 1.500
KWD 1.5      as we render it       KWD 1.50

A yen amount is shown with cents that the currency does not have, and a dinar amount is shown with one fewer digit than it does have.

Mechanism — two paths, same shape

  1. formatCurrency (packages/fields/src/index.tsx) picks its width from the value's wholeness, never from the currency:

    const fracDigits = isWhole ? 0 : 2;   // 2 for every currency on earth
  2. formatAmount (packages/fields/src/widgets/CurrencyField.tsx) uses the field's declared precision for both bounds, which has the same effect whenever precision disagrees with the currency (and precision defaults to 2).

The shared formatDisplayNumber in @object-ui/i18n is not implicated: it passes the bounds it is handed straight to Intl and would honour the currency default if the call sites simply omitted them.

Reachability, honestly

Needs a tenant or field whose currency is not a 2-decimal one. resolveFieldCurrency resolves from the field, currencyConfig.defaultCurrency, or the tenant default (ADR-0053), so any of the three set to JPY/KRW/CLP (0-decimal) or KWD/BHD/OMR/TND (3-decimal) reaches it. The example apps use USD, so it is not visible in dogfooding today. Filing it plainly rather than grading it — the impact section of a finding is the least reliable part of it.

Not fixed in #4332's PR, and why

Found while fixing #4332 (minimumFractionDigits was a constant 0 against a wholeness-switched maximum, so 1234.5 rendered $1,234.5). That card's scope is the min/max pairing at one policy point; which width is correct for a given currency is a different question with a different answer shape, and it is pre-existing on both sides of that change — the PR moves the second digit of an amount that was already showing decimals a yen amount should not have.

It also is not a one-line flip, which is the main reason it is separate:

  • Dropping both bounds and letting Intl decide would give JPY and KWD the right digits, but would also retire the Salesforce whole-number convention that formatCurrency's doc comment states and that Console: number cell/display renderers hardcode a grouping Intl.NumberFormat('en-US') — an ordinal Field.number (a year) always renders 2,026, and no field property can turn it off #4033 pinned ($1,234 keeps no .00) — Intl's own default for USD 1234 is $1,234.00.
  • Keeping the convention while respecting the currency means deriving the currency's digit count first (Intl.NumberFormat(locale, { style: 'currency', currency }).resolvedOptions().maximumFractionDigits) and switching wholeness against that instead of a literal 2.
  • CurrencyField's precision is authorable metadata, so there is a contract question underneath: does a declared precision: 2 on a JPY field win over the currency's own 0, or is that combination something publish-time validation should reject? Contract-first, that is a spec question, not a renderer question.

Suggested first step is a decision on the third bullet, since it decides whether the fix is renderer-local at all.

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. yinlianghui commented on Aug 12, 2026

    @yinlianghui
    ContributorAuthor

    CLAIM + RULING — PM seat session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4361-currency-minor-units, one dev agent, one PR, Fixes #4361. finding → pm:queue.

    Ruling on the card's three bullets (delegated authority, veto window open):

    1. formatCurrency: derive the currency's own digit count first (Intl.NumberFormat(locale, { style: 'currency', currency }).resolvedOptions().maximumFractionDigits) and switch wholeness against THAT — whole values keep 0 fraction digits (the Salesforce convention Console: number cell/display renderers hardcode a grouping Intl.NumberFormat('en-US') — an ordinal Field.number (a year) always renders 2,026, and no field property can turn it off #4033 pinned survives, extended consistently to 0- and 3-digit currencies), non-whole values take the currency's own count. JPY 1234.5 → ¥1,235; KWD 1.5 → KWD 1.500; USD behavior byte-identical to today. The Console: number cell/display renderers hardcode a grouping Intl.NumberFormat('en-US') — an ordinal Field.number (a year) always renders 2,026, and no field property can turn it off #4033/formatCurrency drops a real cents digit: an amount of 1234.5 renders $1,234.5, not the $1,234.50 its own contract promises #4332/fix(fields): formatCurrency keeps both cents digits on a fractional amount #4362 pins must keep passing unchanged for USD; new pins cover the non-2-digit currencies.
    2. CurrencyField.formatAmount: explicitly authored precision wins — it is authored metadata, and the repo's convention is authored-metadata-keeps-priority (same shape as fix(components): ActionParamDialog's select placeholder reads the common.select pack key #4397's param.placeholder). Absent precision derives from the currency instead of defaulting to 2. The dev must MEASURE whether "absent" is distinguishable from "defaulted 2" by the time the renderer sees it — if the default is baked in upstream of the renderer, that's a stop-and-report shape, not a guess.
    3. The contract question goes upstream, contract-first: whether publish-time validation should reject a declared precision that contradicts the currency's ISO 4217 digits is a spec question. The dev files the objectstack card (contract-first shape, citing this ruling and the measured table), links it here, and the renderer ruling above stands as objectui's behavior pending the upstream answer — no waiting, no coupling.

    Generated by Claude Code

  3. yinlianghui commented on Aug 12, 2026

    @yinlianghui
    ContributorAuthor

    Both halves of the ruling landed in draft PR #4413. The upstream contract card from ruling point 3 is objectstack-ai/objectstack#7918.

    Ruling point 2 — the measurement, since it was a stop-and-report condition. "Absent" IS distinguishable from "defaulted 2" at the renderer, so the half landed rather than stopping:

    where shape carries a materialized 2?
    CurrencyFieldMetadata.precision (packages/types/src/field-types.ts) precision?: number no
    the currency field's precision in @objectstack/spec@17.0.0-rc.6 z.ZodOptional(z.ZodNumber), no .default() no
    CurrencyConfigSchema.precision (packages/spec/src/data/field.zod.ts) z.number().int().min(0).max(10).default(2) yes
    the ?? 2 itself CurrencyField.tsx line 55 it was the renderer's own

    The .default(2) that exists in the spec is on currencyConfig.precision — a different key on a different object, which this widget never reads. The field's own key reaches the renderer as undefined.

    Measured before/after (node 22, full ICU 78.2; the code-to-amount separator is U+00A0, shown as a space):

    currency value before after
    JPY 1234.5 ¥1,234.50 ¥1,235
    KWD 1.5 KWD 1.50 KWD 1.500
    CLP 1234.5 CLP 1,234.50 CLP 1,235
    BHD 2.5 BHD 2.50 BHD 2.500
    ISK 99.5 ISK 99.50 ISK 100
    KWD 1 KWD 1 KWD 1
    USD 1234.5 $1,234.50 $1,234.50
    USD 1234 $1,234 $1,234

    Ruling point 1 confirmed as specified: the whole-number convention survives and is extended, so KWD 1 stays KWD 1 rather than becoming the KWD 1.000 a bare Intl default would give, and every 2-decimal rendering is byte-identical — the #4033 and #4332 / #4362 pins pass unchanged (82 files / 1297 tests green in packages/fields, plus a 305-file / 3892-test sweep over every formatCurrency consumer).

    One thing worth flagging beyond the ruling's letter: because the derived value is CurrencyField's single precision, it also reaches the spinner step and the blur rounding. A JPY field with no declared precision now offers step="1" and rounds typed input to whole yen instead of offering 0.01 for a currency with no minor unit. That is pinned; it is called out in the PR body in case the PM reads it as wider than intended.


    Generated by Claude Code

  4. added a commit that references this issue on Aug 17, 2026
    0f21348
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions