Commit 6c82a02fa03 for woocommerce
commit 6c82a02fa03d4ca5e485801c1eea4ae84b6c01b2
Author: Andrew Matia <73502748+drewmt@users.noreply.github.com>
Date: Thu Aug 20 18:41:29 2026 +0300
Unify postcode validation between PHP and Checkout blocks (#67780)
* Unify postcode validation rules
* Refine shared postcode validation compatibility
* Guard postcode validation runtime inputs
* Guard shared postcode rule lookup
diff --git a/plugins/woocommerce/changelog/66953-unify-postcode-validation b/plugins/woocommerce/changelog/66953-unify-postcode-validation
new file mode 100644
index 00000000000..a687942769c
--- /dev/null
+++ b/plugins/woocommerce/changelog/66953-unify-postcode-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Unify postcode validation rules used by the server and Checkout blocks.
diff --git a/plugins/woocommerce/client/blocks/bin/generate-postcode-validation-rules.js b/plugins/woocommerce/client/blocks/bin/generate-postcode-validation-rules.js
new file mode 100644
index 00000000000..b4d3a61105f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/bin/generate-postcode-validation-rules.js
@@ -0,0 +1,181 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+
+/**
+ * External dependencies
+ */
+const fs = require( 'fs' );
+const path = require( 'path' );
+const {
+ POSTCODE_REGEXES,
+} = require( 'postcode-validator/lib/cjs/postcode-regexes.js' );
+const { version } = require( 'postcode-validator/package.json' );
+const { dependencies } = require( '../package.json' );
+
+const OUTPUT_PATH = path.resolve(
+ __dirname,
+ '../../../i18n/postcode-validation-rules.json'
+);
+
+// Countries with an explicit server-side rule or a Blocks override before the
+// validators were unified. Country expansion is intentionally handled
+// separately.
+const COUNTRY_CODES = [
+ 'AT',
+ 'BA',
+ 'BE',
+ 'BR',
+ 'CA',
+ 'CH',
+ 'CZ',
+ 'DE',
+ 'DK',
+ 'EE',
+ 'ES',
+ 'FI',
+ 'FR',
+ 'GB',
+ 'HU',
+ 'IE',
+ 'IN',
+ 'IT',
+ 'JP',
+ 'KH',
+ 'LI',
+ 'LV',
+ 'MN',
+ 'NI',
+ 'NL',
+ 'NO',
+ 'PL',
+ 'PR',
+ 'PT',
+ 'SE',
+ 'SI',
+ 'SK',
+ 'US',
+];
+
+// Preserve WooCommerce's existing explicit validation behaviour where it is
+// intentionally different from the upstream package. These overrides are
+// applied only while generating the shared artifact.
+const COMPATIBILITY_OVERRIDES = {
+ AT: { pattern: '[0-9]{4}' },
+ BA: { pattern: '[7-8][0-9]{4}' },
+ CA: {
+ pattern:
+ '[ABCEGHJKLMNPRSTVXY][0-9][ABCEGHJKLMNPRSTVWXYZ] ?[0-9][ABCEGHJKLMNPRSTVWXYZ][0-9]',
+ flags: 'i',
+ },
+ CZ: { pattern: '(?:CZ-)?[0-9]{3}\\s?[0-9]{2}' },
+ DE: { pattern: '(?:0[1-9]|[1-9][0-9])[0-9]{3}' },
+ DK: { pattern: '(?:DK-)?(?:[1-24-9]\\d{3}|3[0-8]\\d{2})' },
+ ES: { pattern: '[0-9]{5}' },
+ FI: { pattern: '[0-9]{5}' },
+ FR: { pattern: '[0-9]{5}', flags: 'i' },
+ GB: {
+ pattern:
+ '(?:[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy]?[0-9]{1,2}[0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][0-9][abcdefghjkpstuw][0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy][0-9][abehmnprvwxy][0-9][abdefghjlnpqrstuwxyz]{2}|gir0aa|bfpo[0-9]{1,4}|bfpoc\\/o[0-9]{1,3})',
+ flags: 'i',
+ normalization: 'removeSpaces',
+ },
+ IE: {
+ pattern: '(?:[AC-FHKNPRTV-Y][0-9]{2}|D6W)[0-9AC-FHKNPRTV-Y]{4}',
+ flags: 'i',
+ normalization: 'removeSpacesAndHyphens',
+ },
+ IN: { pattern: '[1-9][0-9]{2}\\s?[0-9]{3}' },
+ JP: { pattern: '[0-9]{3}-?[0-9]{4}' },
+ KH: { pattern: '[0-9]{6}' },
+ LI: { pattern: '94[8-9][0-9]' },
+ LV: { pattern: '(?:LV[- ]?)?[1-9][0-9]{3}', flags: 'i' },
+ MN: { pattern: '[0-9]{5}(?:-[0-9]{4})?' },
+ NI: { pattern: '[1-9][0-9]{4}' },
+ NL: {
+ pattern: '[1-9][0-9]{3}\\s?(?!SA|SD|SS)[A-Z]{2}',
+ flags: 'i',
+ },
+ PR: { pattern: '[0-9]{5}(?:-[0-9]{4})?', flags: 'i' },
+ PT: { pattern: '[0-9]{4}-[0-9]{3}' },
+ SE: { pattern: '(?:SE-)?[0-9]{3}\\s?[0-9]{2}' },
+ SI: { pattern: '[1-9][0-9]{3}' },
+ SK: { pattern: '(?:SK-)?[0-9]{3}\\s?[0-9]{2}' },
+};
+
+if ( dependencies[ 'postcode-validator' ] !== version ) {
+ throw new Error(
+ `Installed postcode-validator ${ version } does not match the ${ dependencies[ 'postcode-validator' ] } pin`
+ );
+}
+
+/**
+ * Remove the ECMAScript anchors supplied by postcode-validator. Consumers add
+ * native anchors so PHP can use \A/\z while JavaScript uses ^/$.
+ *
+ * @param {RegExp} regex Upstream regular expression.
+ * @return {string} Portable, unanchored expression source.
+ */
+function removeAnchors( regex ) {
+ if ( ! regex.source.startsWith( '^' ) || ! regex.source.endsWith( '$' ) ) {
+ throw new Error( `Expected an anchored expression: ${ regex.source }` );
+ }
+
+ return regex.source.slice( 1, -1 );
+}
+
+/**
+ * Replace ECMAScript whitespace tokens with literal spaces. JavaScript's \s
+ * also matches Unicode whitespace that the PHP validator rejects before
+ * applying country-specific rules.
+ *
+ * @param {string} pattern Regular expression source.
+ * @return {string} Expression source with portable space matching.
+ */
+function replaceWhitespaceTokens( pattern ) {
+ return pattern.replaceAll( '\\s', '[ ]' );
+}
+
+const rules = Object.fromEntries(
+ COUNTRY_CODES.map( ( countryCode ) => {
+ const upstreamRegex = POSTCODE_REGEXES.get( countryCode );
+ if ( ! upstreamRegex ) {
+ throw new Error(
+ `No postcode-validator rule for ${ countryCode }`
+ );
+ }
+
+ const sourceRule = {
+ pattern: removeAnchors( upstreamRegex ),
+ ...( upstreamRegex.flags ? { flags: upstreamRegex.flags } : {} ),
+ ...COMPATIBILITY_OVERRIDES[ countryCode ],
+ };
+ const rule = {
+ ...sourceRule,
+ pattern: replaceWhitespaceTokens( sourceRule.pattern ),
+ };
+
+ if ( rule.pattern.includes( '~' ) ) {
+ throw new Error( `Unsupported delimiter in ${ countryCode } rule` );
+ }
+ if ( rule.flags && rule.flags !== 'i' ) {
+ throw new Error( `Unsupported flags in ${ countryCode } rule` );
+ }
+
+ // Catch malformed generated rules before they reach either consumer.
+ new RegExp( `^(?:${ rule.pattern })$`, rule.flags || '' );
+
+ return [ countryCode, rule ];
+ } )
+);
+
+const artifact = {
+ generatedFrom: {
+ package: 'postcode-validator',
+ version,
+ },
+ rules,
+};
+
+fs.writeFileSync(
+ OUTPUT_PATH,
+ `${ JSON.stringify( artifact, null, '\t' ) }\n`
+);
diff --git a/plugins/woocommerce/client/blocks/package.json b/plugins/woocommerce/client/blocks/package.json
index 7b1a3a1dcd7..dece5662dcf 100644
--- a/plugins/woocommerce/client/blocks/package.json
+++ b/plugins/woocommerce/client/blocks/package.json
@@ -51,6 +51,7 @@
"build:docs:block-references": "node ./bin/gen-block-list-doc.js",
"postbuild:docs": "./bin/add-doc-footer.sh",
"dev": "rimraf ../../assets/client/blocks/* && BABEL_ENV=default webpack",
+ "generate:postcode-validation": "node ./bin/generate-postcode-validation-rules.js",
"lint": "pnpm --if-present '/^lint:lang:.*$/'",
"lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
"lint:fix:lang:css": "pnpm lint:css-fix",
@@ -268,7 +269,7 @@
"fast-deep-equal": "^3.1.3",
"fast-sort": "^3.4.0",
"html-react-parser": "3.0.4",
- "postcode-validator": "3.9.2",
+ "postcode-validator": "3.10.22",
"preact": "^10.24.2",
"prop-types": "^15.8.1",
"react-number-format": "5.4.5",
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts
index b50e97be32b..b71b95bbc68 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts
@@ -6,21 +6,34 @@ import {
postcodeValidatorExistsForCountry,
} from 'postcode-validator';
-const CUSTOM_REGEXES = new Map< string, RegExp >( [
- [ 'BA', /^([7-8]{1})([0-9]{4})$/ ],
- [
- 'GB',
- /^([A-Z]){1}([0-9]{1,2}|[A-Z][0-9][A-Z]|[A-Z][0-9]{2}|[A-Z][0-9]|[0-9][A-Z]){1}([ ])?([0-9][A-Z]{2}){1}|BFPO(?:\s)?([0-9]{1,4})$|BFPO(c\/o[0-9]{1,3})$/i,
- ],
- [ 'IN', /^[1-9]{1}[0-9]{2}\s{0,1}[0-9]{3}$/ ],
- [ 'JP', /^([0-9]{3})([-]?)([0-9]{4})$/ ],
- [ 'KH', /^[0-9]{6}$/ ], // Cambodia (6-digit postal code).
- [ 'LI', /^(94[8-9][0-9])$/ ],
- [ 'MN', /^[0-9]{5}(-[0-9]{4})?$/ ], // Mongolia (5-digit postal code or 5-digit postal code followed by a hyphen and 4-digit postal code).
- [ 'NI', /^[1-9]{1}[0-9]{4}$/ ], // Nicaragua (5-digit postal code)
- [ 'NL', /^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i ],
- [ 'SI', /^([1-9][0-9]{3})$/ ],
-] );
+/**
+ * Internal dependencies
+ */
+import postcodeValidationData from '../../../../../../../i18n/postcode-validation-rules.json';
+
+type PostcodeValidationRule = {
+ pattern: string;
+ flags?: string;
+ normalization?: 'removeSpaces' | 'removeSpacesAndHyphens';
+};
+
+const SHARED_RULES = postcodeValidationData.rules as Record<
+ string,
+ PostcodeValidationRule
+>;
+
+const normalizePostcode = (
+ postcode: string,
+ normalization?: PostcodeValidationRule[ 'normalization' ]
+): string => {
+ if ( normalization === 'removeSpaces' ) {
+ return postcode.replace( / /g, '' );
+ }
+ if ( normalization === 'removeSpacesAndHyphens' ) {
+ return postcode.trim().replace( /[\s-]/g, '' );
+ }
+ return postcode;
+};
export interface IsPostcodeProps {
postcode: string;
@@ -28,9 +41,25 @@ export interface IsPostcodeProps {
}
const isPostcode = ( { postcode, country }: IsPostcodeProps ): boolean => {
- const customRegex = CUSTOM_REGEXES.get( country );
- if ( customRegex ) {
- return customRegex.test( postcode );
+ if ( typeof postcode !== 'string' || typeof country !== 'string' ) {
+ return false;
+ }
+
+ // Mirror WC_Validation::is_postcode(): only ASCII whitespace, letters,
+ // digits, and hyphens may reach country-specific validation.
+ if ( /[^ \t\n\r\f\vA-Za-z0-9-]/.test( postcode ) ) {
+ return false;
+ }
+
+ if ( Object.hasOwn( SHARED_RULES, country ) ) {
+ const sharedRule = SHARED_RULES[ country ];
+ const regex = new RegExp(
+ `^(?:${ sharedRule.pattern })$`,
+ sharedRule.flags || ''
+ );
+ return regex.test(
+ normalizePostcode( postcode, sharedRule.normalization )
+ );
}
// If the country is not in the upstream list, trying to validate it would throw, so we skip and assume
// that it is valid.
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts
index a8049816505..2804d3bc051 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts
@@ -3,13 +3,14 @@
*/
import isPostcode from '../is-postcode';
import type { IsPostcodeProps } from '../is-postcode';
+import postcodeValidationFixtures from './postcode-validation-fixtures.json';
describe( 'isPostcode', () => {
const cases = [
// Austrian postcodes
[ true, '1000', 'AT' ],
[ true, '9999', 'AT' ],
- [ false, '0000', 'AT' ],
+ [ true, '0000', 'AT' ],
[ false, '10000', 'AT' ],
// Bosnian postcodes
@@ -64,7 +65,7 @@ describe( 'isPostcode', () => {
// French postcodes
[ true, '01000', 'FR' ],
[ true, '99999', 'FR' ],
- [ true, '01 000', 'FR' ],
+ [ false, '01 000', 'FR' ],
[ false, '1234', 'FR' ],
// British postcodes
@@ -87,6 +88,7 @@ describe( 'isPostcode', () => {
// Irish postcodes
[ true, 'A65F4E2', 'IE' ],
+ [ true, 'a65f4e2', 'IE' ],
[ true, 'A65 F4E2', 'IE' ],
[ true, 'A65-F4E2', 'IE' ],
[ false, 'B23F854', 'IE' ],
@@ -190,10 +192,21 @@ describe( 'isPostcode', () => {
[ true, '12345', 'TW' ],
[ true, '123', 'TW' ],
+ // Countries using the postcode-validator fallback still apply the
+ // character guard shared with PHP.
+ [ true, '2000', 'AU' ],
+ [ false, '2000#', 'AU' ],
+
// Unknown country codes — assumed valid since no regex applies.
[ true, '12345', 'XX' ],
[ true, 'anything', 'ZZ' ],
[ true, '', 'XX' ],
+ [ false, 'anything#', 'XX' ],
+
+ // Object prototype keys are unknown country codes, not shared rules.
+ [ true, '12345', 'constructor' ],
+ [ true, '12345', '__proto__' ],
+ [ true, '12345', 'toString' ],
];
test.each( cases )( '%s: %s for %s', ( result, postcode, country ) =>
@@ -201,4 +214,28 @@ describe( 'isPostcode', () => {
result
)
);
+
+ test( 'returns false for non-string runtime values', () => {
+ expect(
+ isPostcode( {
+ postcode: null,
+ country: 'GB',
+ } as unknown as IsPostcodeProps )
+ ).toBe( false );
+ expect(
+ isPostcode( {
+ postcode: 'SW1A 1AA',
+ country: null,
+ } as unknown as IsPostcodeProps )
+ ).toBe( false );
+ } );
+} );
+
+describe( 'shared postcode validation contract', () => {
+ test.each( postcodeValidationFixtures )(
+ '$expected: $postcode for $country',
+ ( { expected, postcode, country } ) => {
+ expect( isPostcode( { postcode, country } ) ).toBe( expected );
+ }
+ );
} );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json
new file mode 100644
index 00000000000..f8ab0887335
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json
@@ -0,0 +1,78 @@
+[
+ { "country": "AT", "postcode": "0000", "expected": true },
+ { "country": "AT", "postcode": "10000", "expected": false },
+ { "country": "BA", "postcode": "71000", "expected": true },
+ { "country": "BA", "postcode": "61000", "expected": false },
+ { "country": "BE", "postcode": "1111", "expected": true },
+ { "country": "BE", "postcode": "111", "expected": false },
+ { "country": "BR", "postcode": "99999-999", "expected": true },
+ { "country": "BR", "postcode": "99999 999", "expected": false },
+ { "country": "CA", "postcode": "A9A 9A9", "expected": true },
+ { "country": "CA", "postcode": "D0A 9A9", "expected": false },
+ { "country": "CH", "postcode": "9999", "expected": true },
+ { "country": "CH", "postcode": "99999", "expected": false },
+ { "country": "CZ", "postcode": "CZ-115 03", "expected": true },
+ { "country": "CZ", "postcode": "CZ-115\t03", "expected": false },
+ { "country": "CZ", "postcode": "1600", "expected": false },
+ { "country": "DE", "postcode": "01000", "expected": true },
+ { "country": "DE", "postcode": "00000", "expected": false },
+ { "country": "DK", "postcode": "DK-1234", "expected": true },
+ { "country": "DK", "postcode": "3900", "expected": false },
+ { "country": "EE", "postcode": "12345", "expected": true },
+ { "country": "EE", "postcode": "1234", "expected": false },
+ { "country": "ES", "postcode": "99999", "expected": true },
+ { "country": "ES", "postcode": "1234", "expected": false },
+ { "country": "FI", "postcode": "00100", "expected": true },
+ { "country": "FI", "postcode": "FI-00100", "expected": false },
+ { "country": "FR", "postcode": "01000", "expected": true },
+ { "country": "FR", "postcode": "01 000", "expected": false },
+ { "country": "GB", "postcode": "BFPO 801", "expected": true },
+ { "country": "GB", "postcode": "99999", "expected": false },
+ { "country": "HU", "postcode": "1234", "expected": true },
+ { "country": "HU", "postcode": "123", "expected": false },
+ { "country": "IE", "postcode": "A65-F4E2", "expected": true },
+ { "country": "IE", "postcode": "a65f4e2", "expected": true },
+ { "country": "IE", "postcode": "A65F4E2\n", "expected": true },
+ { "country": "IE", "postcode": "JUNKA651234JUNK", "expected": false },
+ { "country": "IN", "postcode": "110 001", "expected": true },
+ { "country": "IN", "postcode": "110\t001", "expected": false },
+ { "country": "IN", "postcode": "11 0001", "expected": false },
+ { "country": "IT", "postcode": "99999", "expected": true },
+ { "country": "IT", "postcode": "9999", "expected": false },
+ { "country": "JP", "postcode": "1340088", "expected": true },
+ { "country": "JP", "postcode": "12345", "expected": false },
+ { "country": "KH", "postcode": "123456", "expected": true },
+ { "country": "KH", "postcode": "12345", "expected": false },
+ { "country": "LI", "postcode": "9482", "expected": true },
+ { "country": "LI", "postcode": "9475", "expected": false },
+ { "country": "LV", "postcode": "LV 1050", "expected": true },
+ { "country": "LV", "postcode": "LV-0123", "expected": false },
+ { "country": "LV", "postcode": "LV-1050\n", "expected": false },
+ { "country": "MN", "postcode": "12345-6789", "expected": true },
+ { "country": "MN", "postcode": "1234", "expected": false },
+ { "country": "NI", "postcode": "12345", "expected": true },
+ { "country": "NI", "postcode": "01234", "expected": false },
+ { "country": "NL", "postcode": "3852 gc", "expected": true },
+ { "country": "NL", "postcode": "3852\tgc", "expected": false },
+ { "country": "NL", "postcode": "3852 SA", "expected": false },
+ { "country": "NO", "postcode": "1234", "expected": true },
+ { "country": "NO", "postcode": "123", "expected": false },
+ { "country": "PL", "postcode": "00-001", "expected": true },
+ { "country": "PL", "postcode": "00001", "expected": false },
+ { "country": "PR", "postcode": "12345", "expected": true },
+ { "country": "PR", "postcode": "1234", "expected": false },
+ { "country": "PT", "postcode": "1234-567", "expected": true },
+ { "country": "PT", "postcode": "1234", "expected": false },
+ { "country": "SE", "postcode": "SE-123 45", "expected": true },
+ { "country": "SE", "postcode": "SE-123\t45", "expected": false },
+ { "country": "SE", "postcode": "12 345", "expected": false },
+ { "country": "SI", "postcode": "1234", "expected": true },
+ { "country": "SI", "postcode": "0123", "expected": false },
+ { "country": "SK", "postcode": "SK-010 01", "expected": true },
+ { "country": "SK", "postcode": "SK-010\t01", "expected": false },
+ { "country": "SK", "postcode": "01 001", "expected": false },
+ { "country": "US", "postcode": "90210", "expected": true },
+ { "country": "US", "postcode": "ABCDE", "expected": false },
+ { "country": "XX", "postcode": "anything", "expected": true },
+ { "country": "XX", "postcode": "anything#", "expected": false }
+]
diff --git a/plugins/woocommerce/i18n/postcode-validation-rules.json b/plugins/woocommerce/i18n/postcode-validation-rules.json
new file mode 100644
index 00000000000..47d9fd6a9a5
--- /dev/null
+++ b/plugins/woocommerce/i18n/postcode-validation-rules.json
@@ -0,0 +1,116 @@
+{
+ "generatedFrom": {
+ "package": "postcode-validator",
+ "version": "3.10.22"
+ },
+ "rules": {
+ "AT": {
+ "pattern": "[0-9]{4}"
+ },
+ "BA": {
+ "pattern": "[7-8][0-9]{4}"
+ },
+ "BE": {
+ "pattern": "\\d{4}"
+ },
+ "BR": {
+ "pattern": "\\d{5}[\\-]?\\d{3}"
+ },
+ "CA": {
+ "pattern": "[ABCEGHJKLMNPRSTVXY][0-9][ABCEGHJKLMNPRSTVWXYZ] ?[0-9][ABCEGHJKLMNPRSTVWXYZ][0-9]",
+ "flags": "i"
+ },
+ "CH": {
+ "pattern": "\\d{4}"
+ },
+ "CZ": {
+ "pattern": "(?:CZ-)?[0-9]{3}[ ]?[0-9]{2}"
+ },
+ "DE": {
+ "pattern": "(?:0[1-9]|[1-9][0-9])[0-9]{3}"
+ },
+ "DK": {
+ "pattern": "(?:DK-)?(?:[1-24-9]\\d{3}|3[0-8]\\d{2})"
+ },
+ "EE": {
+ "pattern": "\\d{5}"
+ },
+ "ES": {
+ "pattern": "[0-9]{5}"
+ },
+ "FI": {
+ "pattern": "[0-9]{5}"
+ },
+ "FR": {
+ "pattern": "[0-9]{5}",
+ "flags": "i"
+ },
+ "GB": {
+ "pattern": "(?:[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy]?[0-9]{1,2}[0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][0-9][abcdefghjkpstuw][0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy][0-9][abehmnprvwxy][0-9][abdefghjlnpqrstuwxyz]{2}|gir0aa|bfpo[0-9]{1,4}|bfpoc\\/o[0-9]{1,3})",
+ "flags": "i",
+ "normalization": "removeSpaces"
+ },
+ "HU": {
+ "pattern": "\\d{4}"
+ },
+ "IE": {
+ "pattern": "(?:[AC-FHKNPRTV-Y][0-9]{2}|D6W)[0-9AC-FHKNPRTV-Y]{4}",
+ "flags": "i",
+ "normalization": "removeSpacesAndHyphens"
+ },
+ "IN": {
+ "pattern": "[1-9][0-9]{2}[ ]?[0-9]{3}"
+ },
+ "IT": {
+ "pattern": "\\d{5}"
+ },
+ "JP": {
+ "pattern": "[0-9]{3}-?[0-9]{4}"
+ },
+ "KH": {
+ "pattern": "[0-9]{6}"
+ },
+ "LI": {
+ "pattern": "94[8-9][0-9]"
+ },
+ "LV": {
+ "pattern": "(?:LV[- ]?)?[1-9][0-9]{3}",
+ "flags": "i"
+ },
+ "MN": {
+ "pattern": "[0-9]{5}(?:-[0-9]{4})?"
+ },
+ "NI": {
+ "pattern": "[1-9][0-9]{4}"
+ },
+ "NL": {
+ "pattern": "[1-9][0-9]{3}[ ]?(?!SA|SD|SS)[A-Z]{2}",
+ "flags": "i"
+ },
+ "NO": {
+ "pattern": "\\d{4}"
+ },
+ "PL": {
+ "pattern": "\\d{2}-\\d{3}"
+ },
+ "PR": {
+ "pattern": "[0-9]{5}(?:-[0-9]{4})?",
+ "flags": "i"
+ },
+ "PT": {
+ "pattern": "[0-9]{4}-[0-9]{3}"
+ },
+ "SE": {
+ "pattern": "(?:SE-)?[0-9]{3}[ ]?[0-9]{2}"
+ },
+ "SI": {
+ "pattern": "[1-9][0-9]{3}"
+ },
+ "SK": {
+ "pattern": "(?:SK-)?[0-9]{3}[ ]?[0-9]{2}"
+ },
+ "US": {
+ "pattern": "([0-9]{5})(?:-([0-9]{4}))?"
+ }
+ }
+}
diff --git a/plugins/woocommerce/includes/class-wc-validation.php b/plugins/woocommerce/includes/class-wc-validation.php
index fecdebb7f94..ed127c330bd 100644
--- a/plugins/woocommerce/includes/class-wc-validation.php
+++ b/plugins/woocommerce/includes/class-wc-validation.php
@@ -76,83 +76,8 @@ class WC_Validation {
return false;
}
- switch ( $country ) {
- case 'AT':
- case 'BE':
- case 'CH':
- case 'HU':
- case 'NO':
- $valid = (bool) preg_match( '/^([0-9]{4})$/', $postcode );
- break;
- case 'BA':
- $valid = (bool) preg_match( '/^([7-8]{1})([0-9]{4})$/', $postcode );
- break;
- case 'BR':
- $valid = (bool) preg_match( '/^([0-9]{5})([-])?([0-9]{3})$/', $postcode );
- break;
- case 'DE':
- $valid = (bool) preg_match( '/^([0]{1}[1-9]{1}|[1-9]{1}[0-9]{1})[0-9]{3}$/', $postcode );
- break;
- case 'DK':
- $valid = (bool) preg_match( '/^(DK-)?([1-24-9]\d{3}|3[0-8]\d{2})$/', $postcode );
- break;
- case 'ES':
- case 'FI':
- case 'EE':
- case 'FR':
- case 'IT':
- $valid = (bool) preg_match( '/^([0-9]{5})$/i', $postcode );
- break;
- case 'GB':
- $valid = self::is_gb_postcode( $postcode );
- break;
- case 'IE':
- $valid = (bool) preg_match( '/([AC-FHKNPRTV-Y]\d{2}|D6W)[0-9AC-FHKNPRTV-Y]{4}/', wc_normalize_postcode( $postcode ) );
- break;
- case 'IN':
- $valid = (bool) preg_match( '/^[1-9]{1}[0-9]{2}\s{0,1}[0-9]{3}$/', $postcode );
- break;
- case 'JP':
- $valid = (bool) preg_match( '/^([0-9]{3})([-]?)([0-9]{4})$/', $postcode );
- break;
- case 'PT':
- $valid = (bool) preg_match( '/^([0-9]{4})([-])([0-9]{3})$/', $postcode );
- break;
- case 'PR':
- case 'US':
- $valid = (bool) preg_match( '/^([0-9]{5})(-[0-9]{4})?$/i', $postcode );
- break;
- case 'CA':
- // CA Postal codes cannot contain D,F,I,O,Q,U and cannot start with W or Z. https://en.wikipedia.org/wiki/Postal_codes_in_Canada#Number_of_possible_postal_codes.
- $valid = (bool) preg_match( '/^([ABCEGHJKLMNPRSTVXY]\d[ABCEGHJKLMNPRSTVWXYZ])([\ ])?(\d[ABCEGHJKLMNPRSTVWXYZ]\d)$/i', $postcode );
- break;
- case 'PL':
- $valid = (bool) preg_match( '/^([0-9]{2})([-])([0-9]{3})$/', $postcode );
- break;
- case 'CZ':
- case 'SE':
- case 'SK':
- $valid = (bool) preg_match( "/^($country-)?([0-9]{3})(\s?)([0-9]{2})$/", $postcode );
- break;
- case 'NL':
- $valid = (bool) preg_match( '/^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i', $postcode );
- break;
- case 'SI':
- $valid = (bool) preg_match( '/^([1-9][0-9]{3})$/', $postcode );
- break;
- case 'LI':
- $valid = (bool) preg_match( '/^(94[8-9][0-9])$/', $postcode );
- break;
- case 'LV':
- // An optional case-insensitive LV prefix, followed by at most one hyphen or
- // space, then four digits that do not start with a zero. A literal space
- // rather than \s, and \z rather than $, so newlines and tabs are rejected.
- $valid = (bool) preg_match( '/^(?:LV[- ]?)?[1-9][0-9]{3}\z/i', $postcode );
- break;
- default:
- $valid = true;
- break;
- }
+ $valid = Automattic\WooCommerce\Internal\Utilities\PostcodeValidation::validate( (string) $postcode, (string) $country );
+ $valid = null === $valid ? true : $valid;
return apply_filters( 'woocommerce_validate_postcode', $valid, $postcode, $country );
}
diff --git a/plugins/woocommerce/src/Internal/Utilities/PostcodeValidation.php b/plugins/woocommerce/src/Internal/Utilities/PostcodeValidation.php
new file mode 100644
index 00000000000..bd475b0bf27
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/PostcodeValidation.php
@@ -0,0 +1,77 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+/**
+ * Provides postcode validation from the shared PHP/JavaScript rule artifact.
+ */
+final class PostcodeValidation {
+ /**
+ * Cached generated rules.
+ *
+ * @var array<string, array{pattern: string, flags?: string, normalization?: string}>|null
+ */
+ private static ?array $rules = null;
+
+ /**
+ * Get all generated postcode rules.
+ *
+ * @return array<string, array{pattern: string, flags?: string, normalization?: string}>
+ */
+ public static function get_rules(): array {
+ if ( null !== self::$rules ) {
+ return self::$rules;
+ }
+
+ $rules_file = dirname( __DIR__, 3 ) . '/i18n/postcode-validation-rules.json';
+ if ( ! is_readable( $rules_file ) ) {
+ self::$rules = array();
+ return self::$rules;
+ }
+
+ $contents = file_get_contents( $rules_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $data = false !== $contents ? json_decode( $contents, true ) : null;
+ $rules = is_array( $data ) && isset( $data['rules'] ) && is_array( $data['rules'] ) ? $data['rules'] : array();
+
+ self::$rules = array_filter(
+ $rules,
+ static function ( $rule ): bool {
+ return is_array( $rule ) && isset( $rule['pattern'] ) && is_string( $rule['pattern'] );
+ }
+ );
+
+ return self::$rules;
+ }
+
+ /**
+ * Validate a postcode if a shared rule exists for its country.
+ *
+ * @param string $postcode Postcode to validate.
+ * @param string $country Country code.
+ * @return bool|null Whether the postcode is valid, or null when no usable rule exists.
+ */
+ public static function validate( string $postcode, string $country ): ?bool {
+ $rules = self::get_rules();
+ if ( ! isset( $rules[ $country ] ) ) {
+ return null;
+ }
+
+ $rule = $rules[ $country ];
+ if ( isset( $rule['normalization'] ) ) {
+ switch ( $rule['normalization'] ) {
+ case 'removeSpaces':
+ $postcode = str_replace( ' ', '', $postcode );
+ break;
+ case 'removeSpacesAndHyphens':
+ $postcode = (string) preg_replace( '/[\s\-]/', '', trim( $postcode ) );
+ break;
+ }
+ }
+
+ $flags = isset( $rule['flags'] ) && 'i' === $rule['flags'] ? 'i' : '';
+ $matched = preg_match( '~\A(?:' . $rule['pattern'] . ')\z~' . $flags, $postcode );
+
+ return false === $matched ? null : 1 === $matched;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php b/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php
index 20e3b975b67..998aa2531c0 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php
@@ -133,4 +133,51 @@ class WC_Validation_Test extends \WC_Unit_Test_Case {
public function test_is_postcode( bool $expected, string $postcode, string $country ): void {
$this->assertSame( $expected, WC_Validation::is_postcode( $postcode, $country ) );
}
+
+ /**
+ * The woocommerce_validate_postcode filter can still override a shared rule.
+ */
+ public function test_postcode_filter_can_override_shared_rule(): void {
+ $callback = static function ( $valid, $postcode, $country ) {
+ return 'US' === $country && 'ABCDE' === $postcode ? true : $valid;
+ };
+
+ add_filter( 'woocommerce_validate_postcode', $callback, 10, 3 );
+ try {
+ $this->assertTrue( WC_Validation::is_postcode( 'ABCDE', 'US' ) );
+ } finally {
+ remove_filter( 'woocommerce_validate_postcode', $callback, 10 );
+ }
+ }
+
+ /**
+ * Both postcode validators must produce the same result for the shared
+ * compatibility contract.
+ *
+ * @dataProvider data_provider_postcode_validation_parity
+ *
+ * @param bool $expected Expected result.
+ * @param string $postcode Postcode to validate.
+ * @param string $country Country code.
+ */
+ public function test_postcode_validation_parity( bool $expected, string $postcode, string $country ): void {
+ $this->assertSame( $expected, WC_Validation::is_postcode( $postcode, $country ) );
+ }
+
+ /**
+ * Loads the fixture list also exercised by the Blocks validator.
+ *
+ * @return array<int, array{bool, string, string}>
+ */
+ public function data_provider_postcode_validation_parity(): array {
+ $fixture_path = WC_ABSPATH . 'client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json';
+ $fixtures = json_decode( (string) file_get_contents( $fixture_path ), true, 512, JSON_THROW_ON_ERROR ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local test fixture.
+
+ return array_map(
+ static function ( array $fixture ): array {
+ return array( $fixture['expected'], $fixture['postcode'], $fixture['country'] );
+ },
+ $fixtures
+ );
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 85f170eea56..026b4e5b26f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3538,8 +3538,8 @@ importers:
specifier: 3.0.4
version: 3.0.4(react@18.3.1)
postcode-validator:
- specifier: 3.9.2
- version: 3.9.2
+ specifier: 3.10.22
+ version: 3.10.22
preact:
specifier: ^10.24.2
version: 10.29.1
@@ -18610,8 +18610,9 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
- postcode-validator@3.9.2:
- resolution: {integrity: sha512-C+oaXif+z+mAN1EWDZG/EM2dnrUxRQR0gpMq8VeLSZwHuT3Bqo4hR3+k7OaEevPl0VK/7W27JwEQb24CbdtzuQ==}
+ postcode-validator@3.10.22:
+ resolution: {integrity: sha512-4rQfw8cPGHaAwIJsOAh1RTxr34EkBcKYYgp72+IQrVTPnmmrdz45OsVeese6X7oOkJ19SY/leRP+o1syeWGJuw==}
+ engines: {node: '>=20.8.1'}
postcss-calc@8.2.4:
resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==}
@@ -45879,7 +45880,7 @@ snapshots:
possible-typed-array-names@1.1.0: {}
- postcode-validator@3.9.2: {}
+ postcode-validator@3.10.22: {}
postcss-calc@8.2.4(postcss@8.4.49):
dependencies: