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 )
+			);
 		},

 		/**