Commit 4f737acd210 for woocommerce
commit 4f737acd21017ad6a25e09b73a86ff09f6031697
Author: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com>
Date: Wed Nov 11 21:37:20 2026 +0530
Currency: round formatDecimal/formatDecimalString with numberFormat() so exports match the screen (#70213)
* Delete packages/js/currency/src/utils.tsx
* Delete packages/js/currency/src/test/index.ts
* Add files via upload
* Rename 5-utils.tsx to utils.tsx
* Add files via upload
* Rename 6-index.ts to index.ts
* Add files via upload
* Add changelog for fix in formatDecimal rounding
* Fix precision coercion in formatDecimalString rounding
Refactor rounding logic to handle precision correctly.
* Add regression assertions for unset, null, string and fractional precision
* Reuse numberFormat() in formatDecimal and formatDecimalString
* Update currency tests for display rounding
* Update changelog entry
* Shorten currency changelog entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Mike Jolley <mike.jolley@me.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/packages/js/currency/changelog/fix-formatdecimal-half-away-rounding b/packages/js/currency/changelog/fix-formatdecimal-half-away-rounding
new file mode 100644
index 00000000000..9d7ab3c6d83
--- /dev/null
+++ b/packages/js/currency/changelog/fix-formatdecimal-half-away-rounding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Round `formatDecimal()` and `formatDecimalString()` the same way as displayed amounts, so exported values match the screen.
diff --git a/packages/js/currency/src/test/index.ts b/packages/js/currency/src/test/index.ts
index f3bdf8c1b68..715182bf3b7 100644
--- a/packages/js/currency/src/test/index.ts
+++ b/packages/js/currency/src/test/index.ts
@@ -1,3 +1,8 @@
+/**
+ * External dependencies
+ */
+import { numberFormat } from '@woocommerce/number';
+
/**
* Internal dependencies
*/
@@ -72,6 +77,19 @@ describe( 'currency.formatDecimal', () => {
// @ts-expect-error formatAccount expects a number or string;
expect( currency.formatDecimal( null ) ).toBe( 0 );
} );
+
+ it( 'should round half fractions like numberFormat(), which renders amounts on screen', () => {
+ const currency = Currency();
+ // These are not exactly representable as doubles, e.g. 1.005 * 100
+ // is 100.49999999999999, so rounding the scaled value drops them.
+ expect( currency.formatDecimal( 1.005 ) ).toBe( 1.01 );
+ expect( currency.formatDecimal( 2.675 ) ).toBe( 2.68 );
+ expect( currency.formatDecimal( 9.995 ) ).toBe( 10 );
+ expect( currency.formatDecimal( 0.285 ) ).toBe( 0.29 );
+ // numberFormat() rounds negative halves toward positive infinity.
+ expect( currency.formatDecimal( -2.675 ) ).toBe( -2.67 );
+ expect( currency.formatDecimal( -1.005 ) ).toBe( -1 );
+ } );
} );
describe( 'currency.formatDecimalString', () => {
@@ -118,4 +136,182 @@ describe( 'currency.formatDecimalString', () => {
} )
).toBe( 'format' );
} );
+
+ it( 'should match numberFormat() so exports agree with the on-screen amount', () => {
+ const currency = Currency();
+ const values = [ 1.005, 2.675, 9.995, 0.285, -1.005, -2.675, -0.285 ];
+ values.forEach( ( value ) => {
+ expect( currency.formatDecimalString( value ) ).toBe(
+ numberFormat(
+ {
+ precision: 2,
+ decimalSeparator: '.',
+ thousandSeparator: '',
+ },
+ value
+ )
+ );
+ } );
+ expect( currency.formatDecimalString( 1.005 ) ).toBe( '1.01' );
+ expect( currency.formatDecimalString( -2.675 ) ).toBe( '-2.67' );
+ } );
+
+ it( 'should keep pre-existing behavior when precision is unset, null, a string, or fractional', () => {
+ // toFixed( undefined ), toFixed( null ) and toFixed( NaN ) use zero
+ // decimal places. A null precision is an explicitly supported
+ // configuration (see the 'Currency precision is null' warning in
+ // setCurrency).
+ const unsetPrecision = Currency( { precision: undefined } );
+ expect( unsetPrecision.formatDecimalString( 1.23 ) ).toBe( '1' );
+ const nullPrecision = Currency( { precision: null } );
+ expect( nullPrecision.formatDecimalString( 1.23 ) ).toBe( '1' );
+ // A string precision is normalized by setCurrency via parseInt.
+ const stringPrecision = Currency( { precision: '2' } );
+ expect( stringPrecision.formatDecimalString( 1.23 ) ).toBe( '1.23' );
+ // A fractional precision keeps its integer part, like toFixed().
+ const fractionalPrecision = Currency( { precision: 2.7 } );
+ expect( fractionalPrecision.formatDecimalString( 1.23 ) ).toBe(
+ '1.23'
+ );
+ expect( fractionalPrecision.formatDecimalString( 1.005 ) ).toBe(
+ '1.00'
+ );
+ } );
+
+ it( 'should keep returning Infinity and exponent-notation values as before', () => {
+ const currency = Currency();
+ expect( currency.formatDecimalString( Infinity ) ).toBe( 'Infinity' );
+ expect( currency.formatDecimal( Infinity ) ).toBe( Infinity );
+ expect( currency.formatDecimalString( 1.5e21 ) ).toBe( '1.5e+21' );
+ expect( currency.formatDecimal( 1.5e21 ) ).toBe( 1.5e21 );
+ } );
+} );
+
+describe( 'decimal formatting compatibility', () => {
+ it.each( [ undefined, null, NaN, 'invalid' ] )(
+ 'keeps numeric NaN with precision %s',
+ ( precision ) => {
+ const currency = Currency( { precision } );
+ expect( currency.formatDecimal( 1.23 ) ).toBeNaN();
+ expect( currency.formatDecimalString( 1.23 ) ).toBe( '1' );
+ expect( currency.formatDecimalString( -0.001 ) ).toBe( '-0' );
+ }
+ );
+
+ it.each( [ -2, 101, Infinity, -Infinity ] )(
+ 'keeps string range checks with precision %s',
+ ( precision ) => {
+ const currency = Currency( { precision } );
+ expect( () => currency.formatDecimalString( 1.23 ) ).toThrow(
+ RangeError
+ );
+ }
+ );
+
+ it.each( [ 1e19, -1e19, 1e20, -1e20, 1.5e21, Infinity, -Infinity ] )(
+ 'keeps unformattable value %s',
+ ( value ) => {
+ const currency = Currency();
+ expect( currency.formatDecimal( value ) ).toBe( value );
+ expect( currency.formatDecimalString( value ) ).toBe(
+ value.toFixed( 2 )
+ );
+ }
+ );
+
+ it( 'keeps numeric behavior for fractional and negative precision', () => {
+ for ( const precision of [ 2.7, -2, -0.5 ] ) {
+ const currency = Currency( { precision } );
+ const exponent = Math.pow( 10, precision );
+ expect( currency.formatDecimal( 1.005 ) ).toBe(
+ Math.round( 1.005 * exponent ) / exponent
+ );
+ }
+ } );
+
+ it( 'keeps valid high precision numeric output when the formatter fails', () => {
+ const currency = Currency( { precision: 100 } );
+ expect( currency.formatDecimal( 1.23 ) ).toBe( 1.23 );
+ expect( currency.formatDecimalString( 1.23 ) ).toBe(
+ ( 1.23 ).toFixed( 100 )
+ );
+ } );
+
+ it( 'uses display rounding for zero, negative halves and small values', () => {
+ const currency = Currency();
+ for ( const value of [ -0.001, -0.005, -2.675, 5e-324, 1e-7 ] ) {
+ const displayed = numberFormat(
+ { precision: 2, decimalSeparator: '.', thousandSeparator: '' },
+ value
+ );
+ expect( currency.formatDecimalString( value ) ).toBe( displayed );
+ expect( currency.formatDecimal( value ) ).toBe(
+ Number( displayed )
+ );
+ }
+ } );
+} );
+
+describe( 'decimal formatter exponent boundaries', () => {
+ it.each( [
+ [ 1e18, 3 ],
+ [ -1e18, 3 ],
+ [ 1.23, 21 ],
+ [ 1.23, 100 ],
+ [ 1e-7, 100 ],
+ [ 5e-324, 100 ],
+ ] )( 'keeps finite output for %s at precision %s', ( value, precision ) => {
+ const currency = Currency( { precision } );
+ const exponent = Math.pow( 10, precision );
+ expect( currency.formatDecimal( value ) ).toBe(
+ Math.round( value * exponent ) / exponent
+ );
+ expect( currency.formatDecimalString( value ) ).toBe(
+ value.toFixed( precision )
+ );
+ } );
+
+ it.each( [ 101, 309, Infinity, -Infinity ] )(
+ 'keeps numeric arithmetic at precision %s',
+ ( precision ) => {
+ const currency = Currency( { precision } );
+ const exponent = Math.pow( 10, precision );
+ expect( currency.formatDecimal( 1.23 ) ).toBe(
+ Math.round( 1.23 * exponent ) / exponent
+ );
+ }
+ );
+
+ it( 'keeps no grouping and a decimal point with localized currency settings', () => {
+ const currency = Currency( {
+ precision: 2,
+ decimalSeparator: ',',
+ thousandSeparator: '.',
+ } );
+ expect( currency.formatDecimalString( 1234.005 ) ).toBe( '1234.01' );
+ expect( currency.formatDecimal( 1234.005 ) ).toBe( 1234.01 );
+ } );
+} );
+
+it.each( [ 2.7, -0.5 ] )(
+ 'keeps string coercion at precision %s',
+ ( precision ) => {
+ const currency = Currency( { precision } );
+ expect( currency.formatDecimalString( 1.005 ) ).toBe(
+ ( 1.005 ).toFixed( precision )
+ );
+ }
+);
+
+it( 'keeps display rounding at valid high precision without an artificial safe-integer cutoff', () => {
+ const currency = Currency( { precision: 20 } );
+ expect( currency.formatDecimalString( 1.23 ) ).toBe(
+ '1.23000000000000000000'
+ );
+} );
+
+it( 'rounds negative integer halves like the display', () => {
+ const currency = Currency( { precision: 0 } );
+ expect( currency.formatDecimalString( -939.5 ) ).toBe( '-939' );
+ expect( currency.formatDecimal( -939.5 ) ).toBe( -939 );
} );
diff --git a/packages/js/currency/src/utils.tsx b/packages/js/currency/src/utils.tsx
index a2db8c7b617..31d2878312e 100644
--- a/packages/js/currency/src/utils.tsx
+++ b/packages/js/currency/src/utils.tsx
@@ -210,6 +210,28 @@ const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
setCurrency( currencySetting );
+ /**
+ * Format decimal values with the display formatter, without separators.
+ * Locutus can return a non-numeric string when its exponent shift produces
+ * scientific notation, so retain the previous result in those cases.
+ */
+ function formatDecimalValue( number: number, precision: number ) {
+ if (
+ ! Number.isInteger( precision ) ||
+ precision < 0 ||
+ precision > 100 ||
+ ! Number.isFinite( number ) ||
+ Math.abs( number ) >= 1e21
+ ) {
+ return undefined;
+ }
+ const formatted = numberFormat(
+ { precision, decimalSeparator: '.', thousandSeparator: '' },
+ number
+ );
+ return Number.isFinite( Number( formatted ) ) ? formatted : undefined;
+ }
+
return {
getCurrencyConfig: () => {
return { ...currency };
@@ -235,10 +257,12 @@ const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
return 0;
}
const { precision } = currency;
- return (
- Math.round( number * Math.pow( 10, precision ) ) /
- Math.pow( 10, precision )
- );
+ const formatted = formatDecimalValue( number, precision );
+ if ( formatted !== undefined ) {
+ return Number( formatted );
+ }
+ const exponent = Math.pow( 10, precision );
+ return Math.round( number * exponent ) / exponent;
},
/**
@@ -256,7 +280,10 @@ const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
return '';
}
const { precision } = currency;
- return number.toFixed( precision );
+ return (
+ formatDecimalValue( number, precision ) ??
+ number.toFixed( precision )
+ );
},
/**