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