Commit d7437eb68de for woocommerce
commit d7437eb68def79747a79c217d94260da8049980f
Author: Ayush Pahwa <ayush.pahwa@automattic.com>
Date: Mon Sep 21 22:29:33 2026 +0700
[WOOPLUG-1826] fix: preserve shipping cost precision in the shipping zone method modal (#68805)
* create: add function to get decimal precision of a number
* update: ensure the largest precision is taken for shipping currency view
* chore: add changelog
* test: add coverage for the new function
* update: cap the decimal precision of value
* update: ensure thousand separator values don't cause NAN
* update: add coverage for special separators
diff --git a/plugins/woocommerce/changelog/fix-wooplug-1826-shipping-rate-losing-precision b/plugins/woocommerce/changelog/fix-wooplug-1826-shipping-rate-losing-precision
new file mode 100644
index 00000000000..119c21fb351
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-1826-shipping-rate-losing-precision
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep shipping method costs that have more decimals than the store precision intact in the shipping zone method modal, both when the modal opens and when the merchant leaves the cost field, so saving no longer rounds the stored rate.
diff --git a/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js b/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
index 2a0fd21ffcd..61fa7bdc130 100644
--- a/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
+++ b/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
@@ -99,16 +99,56 @@ function isValidFormattedNumber( value, config ) {
} ); // All decimals use the correct separator
}
+/**
+ * Counts the decimal places in a formatted number string
+ *
+ * Only a plain number qualifies: digits and the configured thousand separator, then the decimal
+ * separator exactly once, then one or more digits at the end. Formulas, shortcodes, a trailing
+ * separator and an empty value count as zero decimals.
+ *
+ * @param {string} value - The formatted number to inspect
+ * @param {Object} config - Configuration object with decimal and thousand separators
+ * @param {string} config.decimalSeparator - Decimal separator (e.g., '.' or ',')
+ * @param {string} config.thousandSeparator - Thousand separator (e.g., ',' or ' ' or '.')
+ * @returns {number} The number of digits after the decimal separator, or 0
+ */
+function getDecimalCount( value, config ) {
+ if (
+ typeof value !== 'string'
+ || ! config
+ || typeof config !== 'object'
+ || typeof config.decimalSeparator !== 'string'
+ || ! config.decimalSeparator
+ ) {
+ return 0;
+ }
+
+ const thousandSeparator = typeof config.thousandSeparator === 'string' ? config.thousandSeparator : '';
+ const escapeForRegExp = ( text ) => text.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
+ const plainNumber = new RegExp( '^[\\d' + escapeForRegExp( config.decimalSeparator ) + escapeForRegExp( thousandSeparator ) + ']+$' );
+ const trimmed = value.trim();
+ if ( ! plainNumber.test( trimmed ) ) {
+ return 0;
+ }
+
+ const parts = trimmed.split( config.decimalSeparator );
+ if ( parts.length !== 2 || ! /^\d+$/.test( parts[ 1 ] ) ) {
+ return 0;
+ }
+
+ return parts[ 1 ].length;
+}
+
// Export for different module systems
if ( typeof module !== 'undefined' && module.exports ) {
// CommonJS (Node.js)
- module.exports = { isValidFormattedNumber };
+ module.exports = { isValidFormattedNumber, getDecimalCount };
} else if ( typeof define === 'function' && define.amd ) {
// AMD
define( [], function () {
- return { isValidFormattedNumber };
+ return { isValidFormattedNumber, getDecimalCount };
} );
} else {
// Browser global
- window.WCNumberValidation = { isValidFormattedNumber };
+ window.WCNumberValidation = { isValidFormattedNumber, getDecimalCount };
}
diff --git a/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js b/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
index d26c8788026..973872d7252 100644
--- a/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
+++ b/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
@@ -1,9 +1,9 @@
/**
- * Test for isValidFormattedNumber method from utils/number-validation.js
+ * Tests for isValidFormattedNumber and getDecimalCount from utils/number-validation.js
*/
// Import the utility function
-const { isValidFormattedNumber } = require('../number-validation');
+const { isValidFormattedNumber, getDecimalCount } = require('../number-validation');
describe( 'Number Validation Utils - isValidFormattedNumber', () => {
@@ -273,3 +273,53 @@ describe( 'Number Validation Utils - isValidFormattedNumber', () => {
} );
} );
} );
+
+describe( 'Number Validation Utils - getDecimalCount', () => {
+ const dotConfig = { decimalSeparator: '.', thousandSeparator: ',' };
+ const commaConfig = { decimalSeparator: ',', thousandSeparator: '.' };
+
+ test( 'should import function from utility file', () => {
+ expect( typeof getDecimalCount ).toBe( 'function' );
+ expect( getDecimalCount.length ).toBe( 2 ); // expects 2 parameters: value and config
+ } );
+
+ test( 'should count the digits after the decimal separator', () => {
+ expect( getDecimalCount( '4.596', dotConfig ) ).toBe( 3 );
+ expect( getDecimalCount( '8.69565', dotConfig ) ).toBe( 5 );
+ expect( getDecimalCount( '4.5', dotConfig ) ).toBe( 1 );
+ expect( getDecimalCount( '4', dotConfig ) ).toBe( 0 );
+ } );
+
+ test( 'should use the configured decimal separator', () => {
+ expect( getDecimalCount( '4,596', commaConfig ) ).toBe( 3 );
+ expect( getDecimalCount( '1.234,567', commaConfig ) ).toBe( 3 );
+ } );
+
+ test( 'should ignore thousand separators and surrounding whitespace', () => {
+ expect( getDecimalCount( '1,234.567', dotConfig ) ).toBe( 3 );
+ expect( getDecimalCount( ' 4.596 ', dotConfig ) ).toBe( 3 );
+ expect( getDecimalCount( '1 234,567', { decimalSeparator: ',', thousandSeparator: ' ' } ) ).toBe( 3 );
+ expect( getDecimalCount( '1_234.567', { decimalSeparator: '.', thousandSeparator: '_' } ) ).toBe( 3 );
+ } );
+
+ test( 'should return 0 for values that are not plain numbers', () => {
+ expect( getDecimalCount( '', dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( '4.', dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( '1.2.3', dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( '10 * [qty]', dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( '10.123 * [qty]', dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( '[fee min_fee="8.69565"]', dotConfig ) ).toBe( 0 );
+ } );
+
+ test( 'should return 0 for invalid inputs without throwing', () => {
+ expect( getDecimalCount( 4.596, dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( null, dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( undefined, dotConfig ) ).toBe( 0 );
+ expect( getDecimalCount( '4.596', null ) ).toBe( 0 );
+ expect( getDecimalCount( '4.596', undefined ) ).toBe( 0 );
+ expect( getDecimalCount( '4.596', 'invalid' ) ).toBe( 0 );
+ expect( getDecimalCount( '4.596', { thousandSeparator: ',' } ) ).toBe( 0 );
+ expect( getDecimalCount( '4.596', { decimalSeparator: 1 } ) ).toBe( 0 );
+ expect( getDecimalCount( '4.596', { decimalSeparator: '' } ) ).toBe( 0 );
+ } );
+} );
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
index 5bf7c0479e9..466d92d643a 100644
--- a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
@@ -517,7 +517,13 @@
// There was an error modifying the decimal, so we leave the original value as-is.
return;
}
- const formattedValue = window.wc.currency.localiseMonetaryValue( config, value );
+ // Keep any decimals beyond the store precision, so the stored cost is not rounded on display.
+ // Cap at 15, the digits a double holds exactly. The formatter returns NaN above 20 decimals.
+ const decimals = Math.min( WCNumberValidation.getDecimalCount( value, config ), 15 );
+ const formatConfig = decimals > config.precision
+ ? Object.assign( {}, config, { precision: decimals } )
+ : config;
+ const formattedValue = window.wc.currency.localiseMonetaryValue( formatConfig, value );
priceInput.attr( 'value', formattedValue );
} );
@@ -708,7 +714,13 @@
$('.wc-shipping-modal-price').on('blur', function() {
const value = $(this).val();
- const formattedValue = window.wc.currency.localiseMonetaryValue( config, value );
+ // Keep any decimals beyond the store precision, so the typed cost is not rounded on blur.
+ // Cap at 15, the digits a double holds exactly. The formatter returns NaN above 20 decimals.
+ const decimals = Math.min( WCNumberValidation.getDecimalCount( value, config ), 15 );
+ const formatConfig = decimals > config.precision
+ ? Object.assign( {}, config, { precision: decimals } )
+ : config;
+ const formattedValue = window.wc.currency.localiseMonetaryValue( formatConfig, value );
$(this).val( formattedValue );
});
}