Commit d85bcba5bc7 for woocommerce

commit d85bcba5bc7a275d8b7cd05750310440ea2f3f53
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Wed Jul 15 13:48:51 2026 +0300

    Fix saved payment methods being limited by the posts_per_page option (#66607)

    * Fix payment token queries being limited by the posts_per_page option

    * Add changelog entry for payment token limit fix

    * Add BC docblock notes to get_tokens methods

    * Improve payment token limit test coverage

    * Fix user deletion leaving payment tokens beyond the customer limit

    * Document limit and page args in get_tokens docblock

    * Fix page zero skipping the first page of payment token results

    * fix(payment-tokens): cap unscoped data store token queries

    Dropping the implicit posts_per_page LIMIT made get_tokens() return every
    matching row when no limit is passed. For queries scoped to a user_id or
    token_id that is both correct and bounded: they ride the user_id index or the
    primary key, and the personal data eraser and user deletion cleanup depend on
    retrieving every token.

    An unscoped query is different. With no user_id, token_id or limit, the only
    predicate left is gateway_id/type, and neither column is indexed, so it is a
    full table scan that materializes every token in the store across all users --
    and WC_Payment_Tokens::get_tokens() then hydrates each row into an object with
    its meta. The old posts_per_page cap silently defused this. No core caller
    queries unscoped today, but the public API now makes it easy for an extension
    to trigger accidentally where it was previously capped.

    Fall back to a ceiling of 500 rows when a query is neither scoped nor given an
    explicit limit, filterable via woocommerce_get_payment_tokens_unscoped_limit.
    Unscoped callers only ever received posts_per_page rows (default 10), so 500 is
    ~50x more permissive than before and cannot regress an existing consumer, while
    capping an accidental store-wide read. Scoped reads stay unlimited, leaving the
    correctness fix untouched. Passing an explicit limit opts out entirely.

    Refs #25025

    * test(payment-tokens): pin the default customer token limit

    Every existing test either overrides the customer token limit filter or creates
    only three tokens, so a change to DEFAULT_CUSTOMER_TOKENS_LIMIT would keep the
    suite green and the new default was effectively unpinned.

    Hook the limit filter purely to capture the incoming value and assert it equals
    the constant. This locks the default without creating 100 tokens.

    Refs #25025

    * docs(payment-tokens): note the deliberate filter omission on user deletion

    Querying tokens through the data store rather than get_customer_tokens() also
    skips the woocommerce_get_customer_payment_tokens filter. That is intentional:
    cleanup must not be narrowed by a display-oriented filter, and it matches the
    personal data eraser, which already omits it. Without a note the omission reads
    as an oversight.

    Refs #25025

    ---------

    Co-authored-by: Vlad Olaru <vlad.olaru@automattic.com>

diff --git a/plugins/woocommerce/changelog/25025-payment-tokens-posts-per-page-limit b/plugins/woocommerce/changelog/25025-payment-tokens-posts-per-page-limit
new file mode 100644
index 00000000000..20707f1d1ca
--- /dev/null
+++ b/plugins/woocommerce/changelog/25025-payment-tokens-posts-per-page-limit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Saved payment methods are no longer capped by the posts_per_page option: customer token queries now default to a dedicated limit of 100 (filterable via woocommerce_get_customer_payment_tokens_limit), and the payment token data store no longer applies a LIMIT to queries scoped to a user_id or token_id unless one is explicitly requested, so the personal data eraser and user deletion clean up all tokens. Unscoped data store queries (no user_id, token_id or limit) match on unindexed columns and read the whole table, so they fall back to a ceiling of 500 rows, filterable via woocommerce_get_payment_tokens_unscoped_limit; pass an explicit limit to opt out.
diff --git a/plugins/woocommerce/includes/class-wc-payment-tokens.php b/plugins/woocommerce/includes/class-wc-payment-tokens.php
index 618b5dd7cc9..b02fe795ad4 100644
--- a/plugins/woocommerce/includes/class-wc-payment-tokens.php
+++ b/plugins/woocommerce/includes/class-wc-payment-tokens.php
@@ -16,10 +16,20 @@ defined( 'ABSPATH' ) || exit;
  */
 class WC_Payment_Tokens {

+	/**
+	 * Default maximum number of tokens returned by `get_customer_tokens`.
+	 *
+	 * @since 11.1.0
+	 * @var int
+	 */
+	const DEFAULT_CUSTOMER_TOKENS_LIMIT = 100;
+
 	/**
 	 * Gets valid tokens from the database based on user defined criteria.
 	 *
 	 * @since  2.6.0
+	 * @since  11.1.0 Results are no longer implicitly limited by the `posts_per_page` option;
+	 *                pass a positive `limit` arg to bound the query.
 	 * @param  array $args Query arguments {
 	 *     Array of query parameters.
 	 *
@@ -27,6 +37,11 @@ class WC_Payment_Tokens {
 	 *     @type string $user_id    User ID.
 	 *     @type string $gateway_id Gateway ID.
 	 *     @type string $type       Token type.
+	 *     @type int    $limit      Maximum number of tokens to return. When omitted or not positive, queries
+	 *                              scoped to a `token_id` or `user_id` are unlimited, while unscoped queries
+	 *                              fall back to a ceiling filterable via
+	 *                              `woocommerce_get_payment_tokens_unscoped_limit`.
+	 *     @type int    $page       Page of results to return when `limit` is set. Default 1.
 	 * }
 	 * @return WC_Payment_Token[]
 	 */
@@ -78,10 +93,11 @@ class WC_Payment_Tokens {
 				 * Controls the maximum number of Payment Methods that will be listed via the My Account page.
 				 *
 				 * @since 7.2.0
+				 * @since 11.1.0 The default changed from the value of the `posts_per_page` option to 100.
 				 *
-				 * @param int $limit Defaults to the value of the `posts_per_page` option.
+				 * @param int $limit Maximum number of tokens to return. Defaults to 100.
 				 */
-				'limit'      => apply_filters( 'woocommerce_get_customer_payment_tokens_limit', get_option( 'posts_per_page' ) ),
+				'limit'      => apply_filters( 'woocommerce_get_customer_payment_tokens_limit', self::DEFAULT_CUSTOMER_TOKENS_LIMIT ),
 			)
 		);

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..15a24749668 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
@@ -23,6 +23,19 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
 	 */
 	protected $meta_type = 'payment_token';

+	/**
+	 * Fallback maximum number of rows returned by `get_tokens()` for queries that are neither
+	 * scoped to a `user_id`/`token_id` nor given an explicit `limit`.
+	 *
+	 * Such a query matches on `gateway_id`/`type` only, and neither column is indexed, so it is a
+	 * full table scan across every user's tokens. This ceiling keeps an accidental store-wide
+	 * query bounded; scoped queries ride the primary key or the `user_id` index and stay unlimited.
+	 *
+	 * @since 11.1.0
+	 * @var int
+	 */
+	const DEFAULT_UNSCOPED_TOKENS_LIMIT = 500;
+
 	/**
 	 * If we have already saved our extra data, don't do automatic / default handling.
 	 *
@@ -228,8 +241,16 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
 	 * Accepts token_id, user_id, gateway_id, and type.
 	 * Each object should contain the fields token_id, gateway_id, token, user_id, type, is_default.
 	 *
+	 * Queries scoped to a `token_id` or `user_id` are unlimited unless a positive `limit` arg is
+	 * passed. An unscoped query (neither of those, nor a `limit`) reads every token in the store, so
+	 * it falls back to DEFAULT_UNSCOPED_TOKENS_LIMIT rows, filterable via
+	 * `woocommerce_get_payment_tokens_unscoped_limit`.
+	 *
 	 * @since 3.0.0
-	 * @param array $args List of accepted args: token_id, gateway_id, user_id, type.
+	 * @since 11.1.0 Results are no longer implicitly limited by the `posts_per_page` option; a `LIMIT`
+	 *               clause is applied when a positive `limit` arg is passed, or as a fallback ceiling
+	 *               on unscoped queries.
+	 * @param array $args List of accepted args: token_id, gateway_id, user_id, type, limit, page.
 	 * @return array
 	 */
 	public function get_tokens( $args ) {
@@ -263,11 +284,34 @@ class WC_Payment_Token_Data_Store extends WC_Data_Store_WP implements WC_Object_
 			$gateway_ids = $gateways->get_payment_gateway_ids();
 		}

-		$page           = isset( $args['page'] ) ? absint( $args['page'] ) : 1;
-		$posts_per_page = absint( isset( $args['limit'] ) ? $args['limit'] : get_option( 'posts_per_page' ) );
+		$page  = isset( $args['page'] ) ? max( 1, absint( $args['page'] ) ) : 1;
+		$limit = isset( $args['limit'] ) ? absint( $args['limit'] ) : 0;
+
+		// Without an explicit limit, a query scoped to a token_id or user_id stays unlimited:
+		// consumers like the personal data eraser and user deletion cleanup rely on retrieving
+		// every matching token, reaching this method via WC_Payment_Tokens::get_tokens(). An
+		// unscoped query has no such natural bound, so it falls back to a ceiling instead.
+		if ( $limit < 1 && ! $args['token_id'] && ! $args['user_id'] ) {
+			/**
+			 * Controls the fallback maximum number of tokens returned by an unscoped query, i.e. one
+			 * passing neither `user_id`/`token_id` nor an explicit `limit`. Such a query matches on
+			 * the unindexed `gateway_id`/`type` columns and would otherwise read every token in the
+			 * store. Pass an explicit `limit` (and `page`) to opt out of this ceiling.
+			 *
+			 * Queries scoped to a `user_id` or `token_id` are unaffected and remain unlimited.
+			 *
+			 * @since 11.1.0
+			 *
+			 * @param int   $limit Maximum number of tokens to return. Defaults to 500.
+			 * @param array $args  The arguments passed to `get_tokens()`.
+			 */
+			$limit = absint( apply_filters( 'woocommerce_get_payment_tokens_unscoped_limit', self::DEFAULT_UNSCOPED_TOKENS_LIMIT, $args ) );
+		}

-		$pgstrt = absint( ( $page - 1 ) * $posts_per_page ) . ', ';
-		$limits = 'LIMIT ' . $pgstrt . $posts_per_page;
+		$limits = '';
+		if ( $limit > 0 ) {
+			$limits = 'LIMIT ' . absint( ( $page - 1 ) * $limit ) . ', ' . $limit;
+		}

 		$gateway_ids[] = '';
 		$where[]       = "gateway_id IN ('" . implode( "','", array_map( 'esc_sql', $gateway_ids ) ) . "')";
diff --git a/plugins/woocommerce/includes/wc-user-functions.php b/plugins/woocommerce/includes/wc-user-functions.php
index 708ac3b89ba..ffdd45c9d17 100644
--- a/plugins/woocommerce/includes/wc-user-functions.php
+++ b/plugins/woocommerce/includes/wc-user-functions.php
@@ -1061,8 +1061,11 @@ function wc_delete_user_data( $user_id ) {
 		)
 	);

-	// Clean up payment tokens.
-	$payment_tokens = WC_Payment_Tokens::get_customer_tokens( $user_id );
+	// Clean up payment tokens. Query without a limit so every token is removed, not just the
+	// customer-facing subset capped by `get_customer_tokens()`. Deliberately bypasses the
+	// `woocommerce_get_customer_payment_tokens` filter too: cleanup must not be narrowed by a
+	// display-oriented filter, and this matches the personal data eraser, which also omits it.
+	$payment_tokens = WC_Payment_Tokens::get_tokens( array( 'user_id' => $user_id ) );

 	foreach ( $payment_tokens as $payment_token ) {
 		$payment_token->delete();
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-payment-tokens-test.php b/plugins/woocommerce/tests/php/includes/class-wc-payment-tokens-test.php
new file mode 100644
index 00000000000..dbb05e8f111
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-payment-tokens-test.php
@@ -0,0 +1,314 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * @package WooCommerce\Tests\PaymentTokens
+ */
+
+/**
+ * Class WC_Payment_Tokens_Test.
+ */
+class WC_Payment_Tokens_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * ID of the customer used in the tests.
+	 *
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_get_customer_payment_tokens_limit' );
+		remove_all_filters( 'woocommerce_get_payment_tokens_unscoped_limit' );
+		remove_all_filters( 'pre_option_posts_per_page' );
+		update_option( 'posts_per_page', 10 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Create a number of credit card tokens for the test customer.
+	 *
+	 * @param int $count Number of tokens to create.
+	 */
+	private function create_tokens_for_user( int $count ): void {
+		for ( $i = 0; $i < $count; $i++ ) {
+			WC_Helper_Payment_Token::create_cc_token( $this->user_id );
+		}
+	}
+
+	/**
+	 * @testdox get_customer_tokens should not be limited by the posts_per_page option (issue 25025).
+	 */
+	public function test_get_customer_tokens_is_not_limited_by_posts_per_page(): void {
+		update_option( 'posts_per_page', 1 );
+		$this->create_tokens_for_user( 3 );
+
+		$this->assertCount(
+			3,
+			WC_Payment_Tokens::get_customer_tokens( $this->user_id ),
+			'All customer tokens should be returned regardless of the posts_per_page option'
+		);
+	}
+
+	/**
+	 * @testdox Data store get_tokens should not consult the posts_per_page option even when it is empty.
+	 */
+	public function test_data_store_get_tokens_ignores_empty_posts_per_page(): void {
+		add_filter( 'pre_option_posts_per_page', '__return_empty_string' );
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		// Regression guard: the data store used to fall back to posts_per_page, and an empty value produced LIMIT 0 (zero rows).
+		$this->assertCount(
+			3,
+			$data_store->get_tokens( array( 'user_id' => $this->user_id ) ),
+			'Token queries must not be affected by the posts_per_page option'
+		);
+	}
+
+	/**
+	 * @testdox get_customer_tokens should respect the woocommerce_get_customer_payment_tokens_limit filter.
+	 */
+	public function test_get_customer_tokens_limit_filter_is_respected(): void {
+		add_filter(
+			'woocommerce_get_customer_payment_tokens_limit',
+			function () {
+				return 2;
+			}
+		);
+		$this->create_tokens_for_user( 3 );
+
+		$this->assertCount(
+			2,
+			WC_Payment_Tokens::get_customer_tokens( $this->user_id ),
+			'The filter should still cap the number of returned tokens'
+		);
+	}
+
+	/**
+	 * @testdox Data store get_tokens should return all tokens when no limit argument is passed.
+	 */
+	public function test_data_store_get_tokens_returns_all_tokens_without_limit(): void {
+		update_option( 'posts_per_page', 1 );
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		$this->assertCount(
+			3,
+			$data_store->get_tokens( array( 'user_id' => $this->user_id ) ),
+			'Without an explicit limit, the data store should return all matching tokens (GDPR eraser and user-deletion cleanup rely on this)'
+		);
+	}
+
+	/**
+	 * @testdox Data store get_tokens should return all tokens when page is passed without a limit.
+	 */
+	public function test_data_store_get_tokens_ignores_page_without_limit(): void {
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		$this->assertCount(
+			3,
+			$data_store->get_tokens(
+				array(
+					'user_id' => $this->user_id,
+					'page'    => 2,
+				)
+			),
+			'A page argument without an explicit limit should not paginate the results'
+		);
+	}
+
+	/**
+	 * @testdox wc_delete_user_data should delete all payment tokens, not just the customer-facing limited subset.
+	 */
+	public function test_wc_delete_user_data_deletes_all_tokens(): void {
+		add_filter(
+			'woocommerce_get_customer_payment_tokens_limit',
+			function () {
+				return 1;
+			}
+		);
+		$this->create_tokens_for_user( 3 );
+
+		wc_delete_user_data( $this->user_id );
+
+		global $wpdb;
+		$remaining = (int) $wpdb->get_var(
+			$wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_payment_tokens WHERE user_id = %d", $this->user_id )
+		);
+		$this->assertSame( 0, $remaining, 'Deleting a user must remove every saved payment token' );
+	}
+
+	/**
+	 * @testdox Data store get_tokens should treat page zero as the first page.
+	 */
+	public function test_data_store_get_tokens_treats_page_zero_as_first_page(): void {
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		$this->assertCount(
+			2,
+			$data_store->get_tokens(
+				array(
+					'user_id' => $this->user_id,
+					'limit'   => 2,
+					'page'    => 0,
+				)
+			),
+			'Page 0 must return the first page of results, not skip past it'
+		);
+	}
+
+	/**
+	 * @testdox get_customer_tokens should default the limit filter to DEFAULT_CUSTOMER_TOKENS_LIMIT.
+	 */
+	public function test_get_customer_tokens_defaults_to_the_documented_limit(): void {
+		$received = null;
+		add_filter(
+			'woocommerce_get_customer_payment_tokens_limit',
+			function ( $limit ) use ( &$received ) {
+				$received = $limit;
+				return $limit;
+			}
+		);
+
+		WC_Payment_Tokens::get_customer_tokens( $this->user_id );
+
+		// Pins the default without creating 100 tokens: a silent change to the constant fails here.
+		$this->assertSame(
+			WC_Payment_Tokens::DEFAULT_CUSTOMER_TOKENS_LIMIT,
+			$received,
+			'The customer token limit filter should receive DEFAULT_CUSTOMER_TOKENS_LIMIT as its default'
+		);
+		$this->assertSame( 100, WC_Payment_Tokens::DEFAULT_CUSTOMER_TOKENS_LIMIT );
+	}
+
+	/**
+	 * @testdox Data store get_tokens should cap an unscoped query with the fallback ceiling.
+	 */
+	public function test_data_store_get_tokens_caps_unscoped_queries(): void {
+		$received = null;
+		add_filter(
+			'woocommerce_get_payment_tokens_unscoped_limit',
+			function ( $limit ) use ( &$received ) {
+				$received = $limit;
+				return 2;
+			}
+		);
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		// An unscoped query matches on the unindexed gateway_id/type columns, so it must not read the
+		// whole table.
+		$this->assertCount(
+			2,
+			$data_store->get_tokens( array() ),
+			'An unscoped query should be capped by the fallback ceiling'
+		);
+
+		// Pins the ceiling's default without creating 500 tokens.
+		$this->assertSame(
+			WC_Payment_Token_Data_Store::DEFAULT_UNSCOPED_TOKENS_LIMIT,
+			$received,
+			'The unscoped limit filter should receive DEFAULT_UNSCOPED_TOKENS_LIMIT as its default'
+		);
+		$this->assertSame( 500, WC_Payment_Token_Data_Store::DEFAULT_UNSCOPED_TOKENS_LIMIT );
+	}
+
+	/**
+	 * @testdox Data store get_tokens should not apply the unscoped ceiling to scoped queries.
+	 */
+	public function test_data_store_get_tokens_ceiling_does_not_apply_to_scoped_queries(): void {
+		add_filter(
+			'woocommerce_get_payment_tokens_unscoped_limit',
+			function () {
+				return 1;
+			}
+		);
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+		$token_ids  = wp_list_pluck( $data_store->get_tokens( array( 'user_id' => $this->user_id ) ), 'token_id' );
+
+		// The eraser and user-deletion cleanup scope by user_id and must stay unlimited.
+		$this->assertCount(
+			3,
+			$data_store->get_tokens( array( 'user_id' => $this->user_id ) ),
+			'A user_id-scoped query must not be capped by the unscoped ceiling'
+		);
+		$this->assertCount(
+			3,
+			$data_store->get_tokens( array( 'token_id' => $token_ids ) ),
+			'A token_id-scoped query must not be capped by the unscoped ceiling'
+		);
+	}
+
+	/**
+	 * @testdox An explicit limit should override the unscoped ceiling.
+	 */
+	public function test_data_store_get_tokens_explicit_limit_overrides_unscoped_ceiling(): void {
+		add_filter(
+			'woocommerce_get_payment_tokens_unscoped_limit',
+			function () {
+				return 1;
+			}
+		);
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		$this->assertCount(
+			3,
+			$data_store->get_tokens( array( 'limit' => 10 ) ),
+			'An explicit limit should take precedence over the unscoped fallback ceiling'
+		);
+	}
+
+	/**
+	 * @testdox Data store get_tokens should respect an explicit limit and page.
+	 */
+	public function test_data_store_get_tokens_respects_explicit_limit_and_page(): void {
+		$this->create_tokens_for_user( 3 );
+
+		$data_store = WC_Data_Store::load( 'payment-token' );
+
+		$this->assertCount(
+			2,
+			$data_store->get_tokens(
+				array(
+					'user_id' => $this->user_id,
+					'limit'   => 2,
+				)
+			),
+			'An explicit limit should cap the results'
+		);
+		$this->assertCount(
+			1,
+			$data_store->get_tokens(
+				array(
+					'user_id' => $this->user_id,
+					'limit'   => 2,
+					'page'    => 2,
+				)
+			),
+			'Pagination should return the remaining tokens on the second page'
+		);
+	}
+}