Commit 209017ad4d4 for woocommerce

commit 209017ad4d42c183c15410311928902e82820e31
Author: Dale du Preez <dale@automattic.com>
Date:   Wed Oct 14 16:08:56 2026 +0200

    Surface more details about invalid token fields via exceptions (#68711)

    * Initial implementation

    * Address linting issues

    * Changelog

    * Update since tags to 11.3.0

diff --git a/plugins/woocommerce/changelog/surface-missing-payment-token-fields b/plugins/woocommerce/changelog/surface-missing-payment-token-fields
new file mode 100644
index 00000000000..53148f57a0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/surface-missing-payment-token-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: tweak
+
+Surface invalid payment token fields via WC_Data_Exception attributes
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-payment-token.php b/plugins/woocommerce/includes/abstracts/abstract-wc-payment-token.php
index 6fc6f634d29..b97ecabe4aa 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-payment-token.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-payment-token.php
@@ -223,11 +223,25 @@ abstract class WC_Payment_Token extends WC_Legacy_Payment_Token {
 	 * @return boolean True if the passed data is valid
 	 */
 	public function validate() {
-		$token = $this->get_prop( 'token', 'edit' );
-		if ( empty( $token ) ) {
+		$invalid_fields = $this->get_invalid_token_fields();
+		if ( array() !== $invalid_fields ) {
 			return false;
 		}
 		return true;
 	}

+	/**
+	 * Get the invalid token fields.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return array<string, mixed> The invalid token fields with the field name as the key and the field value as the value.
+	 */
+	public function get_invalid_token_fields(): array {
+		$token = $this->get_prop( 'token', 'edit' );
+		if ( empty( $token ) ) {
+			return array( 'token' => $token );
+		}
+		return array();
+	}
 }
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php b/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php
index c6d6e5012b5..ad0392a1bbe 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-payment-token-data-store.php
@@ -37,12 +37,11 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
 	 *
 	 * @param WC_Payment_Token $token Payment token object.
 	 *
-	 * @throws Exception Throw exception if invalid or missing payment token fields.
+	 * @throws WC_Data_Exception Thrown when the payment token has specific invalid or missing payment token fields.
+	 * @throws Exception Thrown when the payment token is invalid.
 	 */
 	public function create( &$token ) {
-		if ( false === $token->validate() ) {
-			throw new Exception( __( 'Invalid or missing payment token fields.', 'woocommerce' ) );
-		}
+		$this->validate_token( $token );

 		global $wpdb;
 		if ( ! $token->is_default() && $token->get_user_id() > 0 ) {
@@ -81,12 +80,11 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
 	 *
 	 * @param WC_Payment_Token $token Payment token object.
 	 *
-	 * @throws Exception Throw exception if invalid or missing payment token fields.
+	 * @throws WC_Data_Exception Thrown when the payment token has specific invalid or missing payment token fields.
+	 * @throws Exception Thrown when the payment token is invalid.
 	 */
 	public function update( &$token ) {
-		if ( false === $token->validate() ) {
-			throw new Exception( __( 'Invalid or missing payment token fields.', 'woocommerce' ) );
-		}
+		$this->validate_token( $token );

 		global $wpdb;

@@ -175,6 +173,33 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
 		}
 	}

+	/**
+	 * Validate a payment token.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param WC_Payment_Token $token Payment token object.
+	 * @throws WC_Data_Exception Thrown when the payment token has specific invalid or missing payment token fields.
+	 * @throws Exception Thrown when the payment token is invalid.
+	 */
+	protected function validate_token( $token ): void {
+		if ( false === $token->validate() ) {
+			if ( method_exists( $token, 'get_invalid_token_fields' ) ) {
+				$invalid_fields = $token->get_invalid_token_fields();
+				if ( array() !== $invalid_fields ) {
+					throw new WC_Data_Exception(
+						'woocommerce_invalid_payment_token_fields',
+						__( 'Invalid or missing payment token fields.', 'woocommerce' ),  // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+						0, // Use 0 for backwards compatibility with the Exception below.
+						array( 'invalid_fields' => $invalid_fields ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+					);
+				}
+			}
+
+			throw new Exception( __( 'Invalid or missing payment token fields.', 'woocommerce' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+		}
+	}
+
 	/**
 	 * Read extra data associated with the token (like last4 digits of a card for expiry dates).
 	 *
diff --git a/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-cc.php b/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-cc.php
index f658389846a..041b9f46b9c 100644
--- a/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-cc.php
+++ b/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-cc.php
@@ -81,31 +81,8 @@ class WC_Payment_Token_CC extends WC_Payment_Token {
 	 * @return boolean True if the passed data is valid
 	 */
 	public function validate() {
-		if ( false === parent::validate() ) {
-			return false;
-		}
-
-		if ( ! $this->get_last4( 'edit' ) ) {
-			return false;
-		}
-
-		if ( ! $this->get_expiry_year( 'edit' ) ) {
-			return false;
-		}
-
-		if ( ! $this->get_expiry_month( 'edit' ) ) {
-			return false;
-		}
-
-		if ( ! $this->get_card_type( 'edit' ) ) {
-			return false;
-		}
-
-		if ( 4 !== strlen( $this->get_expiry_year( 'edit' ) ) ) {
-			return false;
-		}
-
-		if ( 2 !== strlen( $this->get_expiry_month( 'edit' ) ) ) {
+		$invalid_fields = $this->get_invalid_token_fields();
+		if ( array() !== $invalid_fields ) {
 			return false;
 		}

@@ -195,4 +172,41 @@ class WC_Payment_Token_CC extends WC_Payment_Token {
 	public function set_last4( $last4 ) {
 		$this->set_prop( 'last4', $last4 );
 	}
+
+	/**
+	 * Get the invalid token fields.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return array<string, mixed> The invalid token fields with the field name as the key and the field value as the value.
+	 */
+	public function get_invalid_token_fields(): array {
+		$invalid_fields = parent::get_invalid_token_fields();
+
+		$last4 = $this->get_last4( 'edit' );
+		if ( ! $last4 ) {
+			$invalid_fields['last4'] = $last4;
+		}
+
+		$expiry_year = $this->get_expiry_year( 'edit' );
+		if ( ! $expiry_year ) {
+			$invalid_fields['expiry_year'] = $expiry_year;
+		} elseif ( 4 !== strlen( $expiry_year ) ) {
+			$invalid_fields['expiry_year'] = $expiry_year;
+		}
+
+		$expiry_month = $this->get_expiry_month( 'edit' );
+		if ( ! $expiry_month ) {
+			$invalid_fields['expiry_month'] = $expiry_month;
+		} elseif ( 2 !== strlen( $expiry_month ) ) {
+			$invalid_fields['expiry_month'] = $expiry_month;
+		}
+
+		$card_type = $this->get_card_type( 'edit' );
+		if ( ! $card_type ) {
+			$invalid_fields['card_type'] = $card_type;
+		}
+
+		return $invalid_fields;
+	}
 }
diff --git a/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-echeck.php b/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-echeck.php
index 4e1e21e7f7a..f4ce70c2149 100644
--- a/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-echeck.php
+++ b/plugins/woocommerce/includes/payment-tokens/class-wc-payment-token-echeck.php
@@ -72,13 +72,11 @@ class WC_Payment_Token_ECheck extends WC_Payment_Token {
 	 * @return boolean True if the passed data is valid
 	 */
 	public function validate() {
-		if ( false === parent::validate() ) {
+		$invalid_fields = $this->get_invalid_token_fields();
+		if ( array() !== $invalid_fields ) {
 			return false;
 		}

-		if ( ! $this->get_last4( 'edit' ) ) {
-			return false;
-		}
 		return true;
 	}

@@ -102,4 +100,22 @@ class WC_Payment_Token_ECheck extends WC_Payment_Token {
 	public function set_last4( $last4 ) {
 		$this->set_prop( 'last4', $last4 );
 	}
+
+	/**
+	 * Get the invalid token fields.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return array<string, mixed> The invalid token fields with the field name as the key and the field value as the value.
+	 */
+	public function get_invalid_token_fields(): array {
+		$invalid_fields = parent::get_invalid_token_fields();
+
+		$last4 = $this->get_last4( 'edit' );
+		if ( ! $last4 ) {
+			$invalid_fields['last4'] = $last4;
+		}
+
+		return $invalid_fields;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-payment-token-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-payment-token-test.php
new file mode 100644
index 00000000000..d6acd4906a9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-payment-token-test.php
@@ -0,0 +1,31 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Class WC_Abstract_Payment_Token_Test file.
+ *
+ * @package WooCommerce\Tests\Abstracts
+ */
+
+/**
+ * Tests for the WC_Payment_Token abstract class.
+ */
+class WC_Abstract_Payment_Token_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should report the token field as invalid only when it is empty.
+	 *
+	 * @testWith ["tok_123", []]
+	 *           ["", {"token": ""}]
+	 *
+	 * @param string $raw_token       Token value to set.
+	 * @param array  $expected_fields Expected invalid fields.
+	 */
+	public function test_get_invalid_token_fields( string $raw_token, array $expected_fields ): void {
+		$token = new WC_Payment_Token_Stub();
+		$token->set_token( $raw_token );
+
+		$this->assertSame( $expected_fields, $token->get_invalid_token_fields(), 'Unexpected invalid fields' );
+		$this->assertSame( array() === $expected_fields, $token->validate(), 'validate() should fail when invalid fields are reported' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-payment-token-data-store-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-payment-token-data-store-test.php
new file mode 100644
index 00000000000..47b1d5979a2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-payment-token-data-store-test.php
@@ -0,0 +1,141 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Class WC_Payment_Token_Data_Store_Test file.
+ *
+ * @package WooCommerce\Tests\DataStores
+ */
+
+/**
+ * Tests for the WC_Payment_Token_Data_Store class.
+ */
+class WC_Payment_Token_Data_Store_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WC_Payment_Token_Data_Store
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new WC_Payment_Token_Data_Store();
+	}
+
+	/**
+	 * @testdox Should throw a data exception listing the invalid fields when creating an invalid token.
+	 */
+	public function test_create_throws_data_exception_with_invalid_fields(): void {
+		$token = new WC_Payment_Token_CC();
+		$token->set_token( 'tok_123' );
+		$token->set_last4( '4242' );
+		$token->set_expiry_year( '30' );
+		$token->set_expiry_month( '08' );
+
+		$exception = null;
+		try {
+			$this->sut->create( $token );
+		} catch ( WC_Data_Exception $e ) {
+			$exception = $e;
+		}
+
+		$this->assertInstanceOf( WC_Data_Exception::class, $exception, 'Creating an invalid token should throw a WC_Data_Exception' );
+		$this->assertSame( 'woocommerce_invalid_payment_token_fields', $exception->getErrorCode() );
+		$this->assertSame( 'Invalid or missing payment token fields.', $exception->getMessage() );
+		$this->assertSame( 0, $exception->getCode() );
+		$this->assertSame(
+			array(
+				'status'         => 0,
+				'invalid_fields' => array(
+					'expiry_year' => '30',
+					'card_type'   => '',
+				),
+			),
+			$exception->getErrorData()
+		);
+		$this->assertSame( 0, $token->get_id(), 'An invalid token should not be inserted' );
+	}
+
+	/**
+	 * @testdox Should throw a data exception listing the invalid fields when updating a token with invalid changes.
+	 */
+	public function test_update_throws_data_exception_with_invalid_fields(): void {
+		$token = WC_Helper_Payment_Token::create_eCheck_token();
+		$token->set_last4( '' );
+
+		$exception = null;
+		try {
+			$this->sut->update( $token );
+		} catch ( WC_Data_Exception $e ) {
+			$exception = $e;
+		}
+
+		$this->assertInstanceOf( WC_Data_Exception::class, $exception, 'Updating a token with invalid changes should throw a WC_Data_Exception' );
+		$this->assertSame( 'woocommerce_invalid_payment_token_fields', $exception->getErrorCode() );
+		$this->assertSame( 'Invalid or missing payment token fields.', $exception->getMessage() );
+		$this->assertSame( 0, $exception->getCode() );
+		$this->assertSame(
+			array(
+				'status'         => 0,
+				'invalid_fields' => array( 'last4' => '' ),
+			),
+			$exception->getErrorData()
+		);
+
+		$stored_token = new WC_Payment_Token_ECheck( $token->get_id() );
+		$this->assertSame( '1234', $stored_token->get_last4(), 'The stored token should keep its previous value' );
+	}
+
+	/**
+	 * @testdox Should throw a generic exception when a token fails validation without reporting invalid fields.
+	 */
+	public function test_throws_generic_exception_when_no_invalid_fields_are_reported(): void {
+		// An extension token that adds its own validate() rule without overriding get_invalid_token_fields().
+		// phpcs:disable Squiz.Commenting
+		$token = new class() extends WC_Payment_Token {
+			protected $type = 'custom';
+
+			public function validate() {
+				return false;
+			}
+		};
+		// phpcs:enable Squiz.Commenting
+		$token->set_token( 'tok_123' );
+
+		$exception = null;
+		try {
+			$this->sut->create( $token );
+		} catch ( Exception $e ) {
+			$exception = $e;
+		}
+
+		$this->assertInstanceOf( Exception::class, $exception, 'Creating an invalid token should throw an exception' );
+		$this->assertSame( 'Invalid or missing payment token fields.', $exception->getMessage() );
+		$this->assertSame( 0, $exception->getCode() );
+		$this->assertNotInstanceOf( WC_Data_Exception::class, $exception, 'Without reported invalid fields there is nothing to attach to a WC_Data_Exception' );
+	}
+
+	/**
+	 * @testdox Should save a token whose validate() override passes even when the core field checks fail.
+	 */
+	public function test_create_respects_validate_override(): void {
+		// An extension token that relaxes the core card field requirements.
+		// phpcs:disable Squiz.Commenting
+		$token = new class() extends WC_Payment_Token_CC {
+			public function validate() {
+				return true;
+			}
+		};
+		// phpcs:enable Squiz.Commenting
+		$token->set_token( 'tok_123' );
+
+		$this->sut->create( $token );
+
+		$this->assertGreaterThan( 0, $token->get_id(), 'A token that passes validate() should be inserted' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/payment-tokens/class-wc-payment-token-cc-test.php b/plugins/woocommerce/tests/php/includes/payment-tokens/class-wc-payment-token-cc-test.php
new file mode 100644
index 00000000000..7f6b6af1372
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/payment-tokens/class-wc-payment-token-cc-test.php
@@ -0,0 +1,57 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Class WC_Payment_Token_CC_Test file.
+ *
+ * @package WooCommerce\Tests\Payment_Tokens
+ */
+
+/**
+ * Tests for the WC_Payment_Token_CC class.
+ */
+class WC_Payment_Token_CC_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should report each missing or malformed card field with its current value.
+	 *
+	 * @testWith [{}, []]
+	 *           [{"token": ""}, {"token": ""}]
+	 *           [{"last4": ""}, {"last4": ""}]
+	 *           [{"expiry_year": ""}, {"expiry_year": ""}]
+	 *           [{"expiry_year": "30"}, {"expiry_year": "30"}]
+	 *           [{"expiry_month": null}, {"expiry_month": ""}]
+	 *           [{"expiry_month": "888"}, {"expiry_month": "888"}]
+	 *           [{"card_type": ""}, {"card_type": ""}]
+	 *           [{"token": "", "last4": "", "expiry_year": "30", "expiry_month": null, "card_type": ""}, {"token": "", "last4": "", "expiry_year": "30", "expiry_month": "", "card_type": ""}]
+	 *
+	 * @param array $props           Props to override on an otherwise valid token. A null value leaves that prop unset.
+	 * @param array $expected_fields Expected invalid fields.
+	 */
+	public function test_get_invalid_token_fields( array $props, array $expected_fields ): void {
+		$props = array_merge(
+			array(
+				'token'        => 'tok_123',
+				'last4'        => '4242',
+				'expiry_year'  => '2030',
+				'expiry_month' => '08',
+				'card_type'    => 'visa',
+			),
+			$props
+		);
+
+		// set_expiry_month() pads '' to '00', so a missing month can only come from never calling the setter.
+		$token = new WC_Payment_Token_CC();
+		$token->set_props(
+			array_filter(
+				$props,
+				static function ( $value ) {
+					return null !== $value;
+				}
+			)
+		);
+
+		$this->assertSame( $expected_fields, $token->get_invalid_token_fields(), 'Unexpected invalid fields' );
+		$this->assertSame( array() === $expected_fields, $token->validate(), 'validate() should fail when invalid fields are reported' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/payment-tokens/class-wc-payment-token-echeck-test.php b/plugins/woocommerce/tests/php/includes/payment-tokens/class-wc-payment-token-echeck-test.php
new file mode 100644
index 00000000000..e3a2b13e677
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/payment-tokens/class-wc-payment-token-echeck-test.php
@@ -0,0 +1,40 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Class WC_Payment_Token_ECheck_Test file.
+ *
+ * @package WooCommerce\Tests\Payment_Tokens
+ */
+
+/**
+ * Tests for the WC_Payment_Token_ECheck class.
+ */
+class WC_Payment_Token_ECheck_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should report each missing eCheck field with its current value.
+	 *
+	 * @testWith [{}, []]
+	 *           [{"last4": ""}, {"last4": ""}]
+	 *           [{"token": "", "last4": ""}, {"token": "", "last4": ""}]
+	 *
+	 * @param array $props           Props to override on an otherwise valid token.
+	 * @param array $expected_fields Expected invalid fields.
+	 */
+	public function test_get_invalid_token_fields( array $props, array $expected_fields ): void {
+		$token = new WC_Payment_Token_ECheck();
+		$token->set_props(
+			array_merge(
+				array(
+					'token' => 'tok_123',
+					'last4' => '6789',
+				),
+				$props
+			)
+		);
+
+		$this->assertSame( $expected_fields, $token->get_invalid_token_fields(), 'Unexpected invalid fields' );
+		$this->assertSame( array() === $expected_fields, $token->validate(), 'validate() should fail  when invalid fields are reported' );
+	}
+}