Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Normative: Allow users to specify rounding based on cash denominations in common use #839

Closed
wants to merge 5 commits into from

Conversation

ben-allen
Copy link
Contributor

@ben-allen ben-allen commented Oct 18, 2023

fix #134
fix #835

For some currencies, the number of fractional digits and rounding increment used when dealing with cash values differs from the fractional digits/rounding increments used in financial contexts, with the cash contexts being limited to what's allowed for by the smallest currency denominations in actual circulation. ISO 4217 data uses the values for financial rounding, which is often inappropriate for common usage, while CLDR's currency data has separate attributes for:

  1. cash fractional digits and financial fractional digits
  2. cash rounding and financial rounding

This PR allows users to specify a new "currencyPrecision" attribute, which can be set to either "cash" (using CLDR's cash data) or "financial" (using CLDR's financial data, which is almost identical to ISO 4217's values).

This PR makes CLDR normative.

Note 1: I'm not confident about the wording used in CurrencyDigits and the new CurrencyRounding AOs to describe the CLDR data pulled in -- this is to be taken as a best first guess rather than a definitive attempt.
Note 2: Current version shows whether "cash" or "financial" rounding has been set in resolvedOptions, which may not be necessary, as the actual values used for fractional digits and rounding increment are already visible elsewhere in resolvedOptions.

…ws rounding currency values to the number of fractional digits corresponding to the smallest currency denomination in circulation
… rounding to smallest cash denominations for CAD, CHF, DKK
Copy link
Contributor

@sffc sffc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks basically right! Obviously we need to present this to TG2 and then TG1 and then make sure there's good test and MDN coverage. This is about the biggest I would want a normative PR to be.

spec/numberformat.html Outdated Show resolved Hide resolved
@sffc
Copy link
Contributor

sffc commented Oct 20, 2023

This PR allows users to specify a new "cashPrecision" attribute, which can be set to either "cash" (using CLDR's cash data) or "financial" (using CLDR's financial data, which is almost identical to ISO 4217's values).

Note that the spec has the attribute named currencyPrecision, not cashPrecision.

@sffc
Copy link
Contributor

sffc commented Oct 20, 2023

I haven't formed a position yet on the normative reference to CLDR. Happy to have that discussion.

@ben-allen
Copy link
Contributor Author

Note that the spec has the attribute named currencyPrecision, not cashPrecision.

Ah, of course! I've edited the comment

…llowing rounding to smallest cash denominations for CAD, CHF, DKK
Copy link
Contributor

@sffc sffc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks about right to me; would like input from @gibson042 on the CLDR normative reference and from the rest of TG2 on the option names

1. Let _mnfdDefault_ be _cDigits_.
1. Let _mxfdDefault_ be _cDigits_.
1. Else,
1. Let _defaultRoundingIncrement_ be 1.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation: not sure how the spec scopes variables but this Let is not in the same lexical scope as the code below that reads from it. If it is a problem, you could Let it be 1 further up and then Set it to something else when needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine; aliases are implicitly scoped to the containing algorithm: https://tc39.es/ecma262/multipage/notational-conventions.html#sec-algorithm-conventions (emphasis mine)

Once declared, an alias may be referenced in any subsequent steps and must not be referenced from steps prior to the alias's declaration.

@@ -215,6 +219,7 @@ <h1>
1. Else,
1. If IsWellFormedCurrencyCode(_currency_) is *false*, throw a *RangeError* exception.
1. Let _currencyDisplay_ be ? GetOption(_options_, *"currencyDisplay"*, ~string~, &laquo; *"code"*, *"symbol"*, *"narrowSymbol"*, *"name"* &raquo;, *"symbol"*).
1. Let _currencyPrecision_ be ? GetOption(_options_, *"currencyPrecision"*, ~string~, &laquo; *"cash"*, *"financial"* &raquo;, *"financial"*).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a reminder to bikeshed all parts of this: the option name and both string options.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the difference between "financial" here vs. "accounting" for currencySign (i.e., should both options instead use the same "accounting" value?).

Copy link
Contributor Author

@ben-allen ben-allen Oct 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My sense is no, since there's situations where you'd want to use one and not the other. I can easily imagine someone using (for example) a personal finance application wanting debts represented by wrapping them in parentheses, but also not caring about anything smaller than a cent.

Avoiding confusion between the options is one reason why I think "financial" would be a better name than "accounting" for this option.

Copy link
Contributor

@gibson042 gibson042 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have substantive comments regarding normatively referencing CLDR and (especially) when this new option should affect output, the latter of which highlights what I believe to be problematic behavior in the current specification when style is "currency" and notation is not "standard".

@@ -215,6 +219,7 @@ <h1>
1. Else,
1. If IsWellFormedCurrencyCode(_currency_) is *false*, throw a *RangeError* exception.
1. Let _currencyDisplay_ be ? GetOption(_options_, *"currencyDisplay"*, ~string~, &laquo; *"code"*, *"symbol"*, *"narrowSymbol"*, *"name"* &raquo;, *"symbol"*).
1. Let _currencyPrecision_ be ? GetOption(_options_, *"currencyPrecision"*, ~string~, &laquo; *"cash"*, *"financial"* &raquo;, *"financial"*).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the difference between "financial" here vs. "accounting" for currencySign (i.e., should both options instead use the same "accounting" value?).

1. Let _cDigits_ be CurrencyDigits(_currency_).
1. Let _currencyPrecision_ be _numberFormat_.[[CurrencyPrecision]].
1. Let _cDigits_ be CurrencyDigits(_currency_, _currencyPrecision_).
1. Let _defaultRoundingIncrement_ be CurrencyRoundingIncrement(_currency_, _currencyPrecision_).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an interaction between {minimum,maximum}FractionDigits and roundingIncrement that I think this is failing to account for by setting defaultRoundingIncrement in ignorance of the former:

const input = 55.555;
for (const maximumFractionDigits of [0, 1, 2]) {
  for (const roundingIncrement of [1, 50]) {
    const nfOptions = {
      style: "currency",
      currency: "USD",
      maximumFractionDigits,
      roundingIncrement,
    };
    let pattern = String(10 ** -maximumFractionDigits).replace(/[0-9]/g, "#");
    const output = new Intl.NumberFormat("en", nfOptions).format(input);
    console.log(`${input} as ${pattern} @ increment ${roundingIncrement}: ${output}`);
  }
}
/* =>
55.555 as # @ increment 1: $56
55.555 as # @ increment 50: $50
55.555 as #.# @ increment 1: $55.6
55.555 as #.# @ increment 50: $55.0
55.555 as #.## @ increment 1: $55.56
55.555 as #.## @ increment 50: $55.50
*/

Given the above, what would we expect from currencyPrecision: "cash", maximumFractionDigits: 0 vs. currencyPrecision: "cash", maximumFractionDigits: 1 vs. currencyPrecision: "cash", maximumFractionDigits: 2?

Speaking personally, I would expect currencyPrecision to always apply at the same absolute position regardless of output precision, which suggests to me that the algorithm needs to account for digits options (and for that matter, notation as well—we arguably have a preexisting bug where currency-derived fractional digit count defaults are applied even when the decimal point is moved away from its absolute position, as in new Intl.NumberFormat("en", { style: "currency", currency, currencyDisplay: "code", notation: "engineering" }).format(12345).replace(/^.*\s/, "") resulting in "12E3" for JPY but "12.35E3" for USD).

Put another way, I think I expect currency-derived precision and corresponding rounding to apply only when notation is "standard" (i.e., not even when the decimal point happens to be in the right place with scientific/engineering/compact notation), and only in that context does it make sense for a new option to further refine the output.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's a good point. Great catch. I should have noticed that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, very good catch!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of thoughts:

  1. It seems reasonable to throw if currencyPrecision: "cash" is set and notation isn't "standard", since cash rounding only really makes sense in standard notation.
  2. If cash rounding only makes sense if notation is "standard", this suggests that the non-cash option for currencyPrecision should be something other than "financial" — maybe just "standard"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an issue not only when notation isn't "standard" but also when a different value was set for minimumFractionDigits or maximumFractionDigits. So the condition when this is used should be exactly the condition when _cDigits_ is used.

I'm kind-of okay with any of the following outcomes:

  1. Do a complicated check that the setting isn't present with conflicting options
  2. Ignore the setting if conflicting options are present (this is how we behave when e.g. currencySign is present but style is not currency)
  3. Define the algorithm similar to how you have it defined here and let people shoot themselves in the foot

Copy link
Contributor Author

@ben-allen ben-allen Oct 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gut feelings when I see those potential outcomes worded that way:

3 seems bad (needlessly confusing for users)
1 seems less bad but not in keeping with the rest of the spec
2 seems least bad

on edit: Making sure I understand 2: if minimumFractionDigits or maximumFractionDigits is set, currencyPrecision is ignored, correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concretely the change would be the following in SetNumberFormatDigitOptions:

Undo your change on this line:

        1. Let _roundingIncrement_ be ? GetNumberOption(_options_, *"roundingIncrement"*, 1, 5000, 1).

And add the following line:

             1. Set _intlObj_.[[MinimumFractionDigits]] to _mnfdDefault_.
             1. Set _intlObj_.[[MaximumFractionDigits]] to _mxfdDefault_.
+            1. Set _intlObj_.[[RoundingIncrement]] to _defaultRoundingIncrement_.

I think that gets the job done?

1. Let _mnfdDefault_ be _cDigits_.
1. Let _mxfdDefault_ be _cDigits_.
1. Else,
1. Let _defaultRoundingIncrement_ be 1.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine; aliases are implicitly scoped to the containing algorithm: https://tc39.es/ecma262/multipage/notational-conventions.html#sec-algorithm-conventions (emphasis mine)

Once declared, an alias may be referenced in any subsequent steps and must not be referenced from steps prior to the alias's declaration.

<emu-alg>
1. Assert: IsWellFormedCurrencyCode(_currency_) is *true*.
1. Assert: _currency_ is equal to the ASCII-uppercase of _currency_.
1. If the Common Locale Data Repository supplemental data pertaining to fractional currency values contains an element with _currency_ as the value of its iso4217 attribute, then
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm comfortable with generalizing beyond ISO 4217, but this seems like the kind of data for which an implementation could conceivably have reasons to diverge from CLDR (i.e., superior local knowledge). So I'd rather have the reference be a recommendation rather than a requirement (aligning with other sections of the spec).

@ben-allen ben-allen changed the title Normative: Use CLDR data for currency rounding and precision, allowing users to specify rounding based on cash denominations in common use Normative: Allow users to specify rounding based on cash denominations in common use Nov 22, 2023
@sffc
Copy link
Contributor

sffc commented Jan 17, 2024

@ben-allen, @romulocintra and I discussed converting this to a proposal that can go through the stage process.

@ryzokuken ryzokuken added s: blocked Status: the issue is blocked on upstream normative labels Feb 22, 2024
@sffc
Copy link
Contributor

sffc commented Feb 23, 2024

TG2 notes: https://github.com/tc39/ecma402/blob/main/meetings/notes-2024-01-18.md#support-cash-rounding-835

Conclusion: Add Test262 and docs for the current spec behavior.

LGTM: SFC, BAN, FYT

@sffc
Copy link
Contributor

sffc commented Aug 27, 2024

Closing this PR but there is still work tracked in the issues linked in the OP

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
normative s: blocked Status: the issue is blocked on upstream
Projects
Status: Previously Discussed
Development

Successfully merging this pull request may close these issues.

Support cash rounding Permit CLDR rather than ISO 4217 for CurrencyDigits?
4 participants