Commit 4ddff39a975 for woocommerce

commit 4ddff39a975a669477c128850478d6a9bbeed0a3
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Wed Jul 8 19:48:30 2026 +0200

    Add admin-list read queries to the subscriptions facade (#66412)

    - Add status filtering, sorting, and search to the contract list query
    - Add per-status counts and a filtered total for status views and pagination
    - Add a batched per-contract line-item count read for an items-per-row column
    - Resolve customer search with an uncapped users-table subquery and clamp out-of-range paging

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-admin-list-queries b/packages/php/woocommerce-subscriptions-engine/changelog/add-admin-list-queries
new file mode 100644
index 00000000000..824479afeae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-admin-list-queries
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add status filtering, sorting, search, per-status counts, and a batched line-item count to the subscriptions list query, exposed on the Subscriptions facade.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index a7d34a8b2d0..ac65d90b134 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -54,19 +54,61 @@ final class Subscriptions {
 	}

 	/**
-	 * List the most-recent subscription contracts, newest first.
+	 * List subscription contracts for an admin list screen - newest first by default, or
+	 * filtered / sorted / paged / searched via a WooCommerce-style args array (cf.
+	 * `wc_get_orders()`). The status + search filter matches {@see self::count()}, so a page
+	 * and its total describe the same set.
 	 *
-	 * @param int $limit  Maximum contracts to return.
-	 * @param int $offset Contracts to skip (for paging).
-	 * @return array<int, Contract> Contracts newest first.
+	 * @param array<string, mixed> $args {
+	 *     Optional. Query args.
+	 *
+	 *     @type int    $limit   Maximum contracts to return. Default 20.
+	 *     @type int    $offset  Contracts to skip (for paging). Default 0.
+	 *     @type string $status  Filter to one status ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}); ignored when empty or invalid.
+	 *     @type string $orderby One of id, next_payment, total, start; default id.
+	 *     @type string $order   ASC or DESC (case-insensitive); default DESC.
+	 *     @type string $search  Numeric term matches contract id or origin order id; text term matches the owning customer.
+	 * }
+	 * @return array<int, Contract> Contracts in the requested order.
 	 */
-	public static function list( int $limit = 20, int $offset = 0 ): array {
-		return ( new ContractRepository() )->query(
-			array(
-				'limit'  => $limit,
-				'offset' => $offset,
-			)
-		);
+	public static function list( array $args = array() ): array {
+		return ( new ContractRepository() )->query( $args );
+	}
+
+	/**
+	 * The contract count per status - the read behind an admin list's status views bar.
+	 * Keyed by every {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus} value (absent statuses are 0); the `All` total
+	 * is the caller's `array_sum()`. Independent of any search or paging.
+	 *
+	 * @return array<string, int> Status => count, every known status present.
+	 */
+	public static function count_by_status(): array {
+		return ( new ContractRepository() )->count_by_status();
+	}
+
+	/**
+	 * The number of contracts matching a list filter - the total behind a list view's
+	 * pagination. Honours the SAME status + search args as {@see self::list()} and ignores
+	 * paging / sort.
+	 *
+	 * @param array<string, mixed> $args Query args (only `status` and `search` are read).
+	 * @return int The matching contract count.
+	 */
+	public static function count( array $args = array() ): int {
+		return ( new ContractRepository() )->count( $args );
+	}
+
+	/**
+	 * The line-item count for a page of contracts - the read behind an admin list's
+	 * "Items" column. One grouped scan over the given ids, returned as a map keyed by
+	 * every requested id (ids with no items are 0), so a list renders an items count
+	 * per row without a per-row query. Ids are de-duplicated and int-cast.
+	 *
+	 * @param array<int, int> $contract_ids Contract ids to count items for.
+	 * @return array<int, int> Contract id => line-item count, one entry per requested id.
+	 */
+	public static function item_counts( array $contract_ids ): array {
+		return ( new ContractRepository() )->count_items_by_contract( $contract_ids );
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index 101cdb55846..27abb9f8eb0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -325,35 +325,296 @@ final class ContractRepository {
 	}

 	/**
-	 * Query a window of contracts for list screens, newest first (id DESC). Hydrated
-	 * lightweight (row only, no children) like {@see self::find_summary()}.
+	 * The columns a list query may order by, mapped from the public sort key to the
+	 * stored column. A whitelist, never raw input, is the ORDER BY column - so an
+	 * `orderby` arg can only ever name one of these four columns.
+	 */
+	private const ORDERBY_COLUMNS = array(
+		'id'           => 'id',
+		'next_payment' => 'next_payment_gmt',
+		'total'        => 'billing_total',
+		'start'        => 'start_gmt',
+	);
+
+	/**
+	 * Query a window of contracts for list screens. Newest first (id DESC) by default,
+	 * or by a whitelisted `orderby`/`order`. Hydrated lightweight (row only, no children)
+	 * like {@see self::find_summary()}.
 	 *
 	 * Takes a WooCommerce-style args array (cf. `wc_get_orders()`) rather than positional
-	 * paging args, so the shape can widen to status / search / sort without a signature
-	 * change. Only the paging args are honoured for now.
+	 * paging args, so the shape widens to status / search / sort without a signature change.
+	 * The status + search filter is shared with {@see self::count()} via
+	 * {@see self::build_list_criteria()}, so a list page and its total count always agree.
 	 *
 	 * @param array<string, mixed> $args {
 	 *     Optional. Query args.
 	 *
-	 *     @type int $limit  Maximum contracts to return. Default 20.
-	 *     @type int $offset Rows to skip (for paging). Default 0.
+	 *     @type int    $limit   Maximum contracts to return. Default 20.
+	 *     @type int    $offset  Rows to skip (for paging). Default 0.
+	 *     @type string $status  Filter to one status ({@see ContractStatus}); ignored when empty or invalid.
+	 *     @type string $orderby One of the {@see self::ORDERBY_COLUMNS} keys (id, next_payment, total, start); default id.
+	 *     @type string $order   ASC or DESC (case-insensitive); default DESC.
+	 *     @type string $search  A numeric term matches contract id or origin order id; a text term matches the owning customer.
 	 * }
-	 * @return array<int, Contract> Contracts newest first.
+	 * @return array<int, Contract> Contracts in the requested order.
 	 */
 	public function query( array $args = array() ): array {
 		global $wpdb;

-		$limit  = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? (int) $args['limit'] : 20;
-		$offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? (int) $args['offset'] : 0;
+		// Clamp to non-negative so a negative arg cannot emit `LIMIT -n` / `OFFSET -n`
+		// (invalid MySQL); mirrors the `$limit < 1` guard in find_due(). The public
+		// facade methods are callable directly, so this is defence in depth beyond the
+		// list table's own paging math.
+		$limit  = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? max( 0, (int) $args['limit'] ) : 20;
+		$offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? max( 0, (int) $args['offset'] ) : 0;

 		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A );
+		list( $where_sql, $where_params ) = $this->build_list_criteria( $args );
+		$order_by                         = $this->build_order_by( $args );
+
+		$params   = $where_params;
+		$params[] = $limit;
+		$params[] = $offset;
+
+		// The WHERE placeholders join dynamically, so the sniff cannot count them; the
+		// ORDER BY column is from a fixed whitelist, never raw input (see build_order_by()).
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT *
+				FROM {$table}
+				WHERE {$where_sql}
+				ORDER BY {$order_by}
+				LIMIT %d OFFSET %d",
+				$params
+			),
+			ARRAY_A
+		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

 		return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
 	}

+	/**
+	 * Build the shared WHERE clause and its ordered placeholder params for a list query -
+	 * the status + search filter that {@see self::query()} and {@see self::count()} apply
+	 * identically, so a page and its total always describe the same set. Paging and sort
+	 * are NOT part of this (they never change which rows match), so `count()` reuses it as is.
+	 *
+	 * The clause is built from placeholders following the {@see self::find_by_customer_id()}
+	 * dynamic-placeholder pattern; the empty-filter case returns `1=1` so the caller can
+	 * always interpolate `WHERE {$where}` unconditionally. A `status` is honoured only when
+	 * a known {@see ContractStatus}; an empty or unknown status is dropped (never injected).
+	 *
+	 * A `search` term:
+	 * - numeric  -> `( id = %d OR origin_order_id = %d )`;
+	 * - non-empty text -> matched against the owning customer's email / display name / login via a
+	 *   users-table subquery ({@see self::build_customer_search_clause()}), so a term matching no
+	 *   customer naturally returns no rows (the subquery is empty) rather than everything, with no
+	 *   cap on how many customers can match.
+	 *
+	 * @param array<string, mixed> $args Query args (only `status` and `search` are read).
+	 * @return array{0: string, 1: array<int, int|string>} The WHERE SQL and its params in order.
+	 */
+	private function build_list_criteria( array $args ): array {
+		$clauses = array();
+		$params  = array();
+
+		$status = isset( $args['status'] ) && is_string( $args['status'] ) ? $args['status'] : '';
+		if ( '' !== $status && ContractStatus::is_valid( $status ) ) {
+			$clauses[] = 'status = %s';
+			$params[]  = $status;
+		}
+
+		$search = isset( $args['search'] ) && is_scalar( $args['search'] ) ? trim( (string) $args['search'] ) : '';
+		if ( '' !== $search ) {
+			if ( ctype_digit( $search ) ) {
+				$clauses[] = '( id = %d OR origin_order_id = %d )';
+				$params[]  = (int) $search;
+				$params[]  = (int) $search;
+			} else {
+				list( $clause, $like_params ) = $this->build_customer_search_clause( $search );
+				$clauses[]                    = $clause;
+				foreach ( $like_params as $like_param ) {
+					$params[] = $like_param;
+				}
+			}
+		}
+
+		$where_sql = array() === $clauses ? '1 = 1' : implode( ' AND ', $clauses );
+
+		return array( $where_sql, $params );
+	}
+
+	/**
+	 * Build the `ORDER BY` fragment from the whitelisted `orderby`/`order` args. The column
+	 * is only ever one of {@see self::ORDERBY_COLUMNS} (default `id`) and the direction only
+	 * `ASC` or `DESC` (default `DESC`), so raw input never reaches the SQL - the fragment is
+	 * safe to interpolate. A stable `id` tiebreak keeps paging deterministic when the primary
+	 * column has ties.
+	 *
+	 * @param array<string, mixed> $args Query args (only `orderby` and `order` are read).
+	 * @return string The `ORDER BY` fragment (without the `ORDER BY` keyword).
+	 */
+	private function build_order_by( array $args ): string {
+		$orderby_key = isset( $args['orderby'] ) && is_string( $args['orderby'] ) ? $args['orderby'] : 'id';
+		$column      = self::ORDERBY_COLUMNS[ $orderby_key ] ?? 'id';
+
+		$order = isset( $args['order'] ) && is_string( $args['order'] ) ? strtoupper( $args['order'] ) : 'DESC';
+		$order = in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
+
+		if ( 'id' === $column ) {
+			return "id {$order}";
+		}
+
+		return "{$column} {$order}, id {$order}";
+	}
+
+	/**
+	 * Build the customer-search clause for a non-numeric term - the text-search half of
+	 * {@see self::build_list_criteria()}. Matches a contract whose owning customer's email,
+	 * display name or login contains the term, via a subquery on the users table rather than a
+	 * materialised id list, so the database bounds a broad term instead of a truncated
+	 * `IN (...)` set (no matches are silently dropped, and `count()` stays accurate). The three
+	 * `LIKE` params are the same escaped term. WP-native user-table access is Integration-layer
+	 * work and stays out of `Core\`.
+	 *
+	 * @param string $term The non-empty, non-numeric search term.
+	 * @return array{0: string, 1: array<int, string>} The clause SQL and its LIKE params in order.
+	 */
+	private function build_customer_search_clause( string $term ): array {
+		global $wpdb;
+
+		$like = '%' . $wpdb->esc_like( $term ) . '%';
+
+		// $wpdb->users is a trusted core table name; the term is bound through the %s LIKE
+		// placeholders when the caller runs this fragment through $wpdb->prepare().
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$clause = "customer_id IN ( SELECT ID FROM {$wpdb->users} WHERE user_email LIKE %s OR display_name LIKE %s OR user_login LIKE %s )";
+
+		return array( $clause, array( $like, $like, $like ) );
+	}
+
+	/**
+	 * The contract count per status - the views bar's read. One `GROUP BY status` scan,
+	 * returned as a map keyed by EVERY {@see ContractStatus::all()} value (absent statuses
+	 * filled with 0) and in that order, so a consumer can render a fixed set of views
+	 * without knowing which statuses currently have rows. The `All` total is the caller's
+	 * `array_sum()`. Independent of any search / paging (WC-style: the views count the whole
+	 * store, not the current page).
+	 *
+	 * @return array<string, int> Status => count, every known status present.
+	 */
+	public function count_by_status(): array {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS total FROM {$table} GROUP BY status", ARRAY_A );
+
+		// Seed every known status at 0 so the map is complete and stably ordered.
+		$counts = array();
+		foreach ( ContractStatus::all() as $status ) {
+			$counts[ $status ] = 0;
+		}
+
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( ! is_array( $row ) ) {
+				continue;
+			}
+			$status = ScalarCoercion::coerce_string( $row['status'] ?? '' );
+			// A row whose status has drifted outside the known set is ignored, not added
+			// as a stray key - the map stays exactly ContractStatus::all().
+			if ( array_key_exists( $status, $counts ) ) {
+				$counts[ $status ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
+			}
+		}
+
+		return $counts;
+	}
+
+	/**
+	 * The number of contracts matching a list filter - the total behind a list view's
+	 * pagination. Applies the SAME status + search WHERE as {@see self::query()} (via
+	 * {@see self::build_list_criteria()}), and ignores paging / sort, so the count always
+	 * describes the full set the page is a window onto.
+	 *
+	 * @param array<string, mixed> $args Query args (only `status` and `search` are read).
+	 * @return int The matching contract count.
+	 */
+	public function count( array $args = array() ): int {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		list( $where_sql, $params ) = $this->build_list_criteria( $args );
+
+		if ( array() === $params ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$total = $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}" );
+		} else {
+			// The WHERE placeholders join dynamically, so the sniff sees none in the literal.
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+			$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params ) );
+		}
+
+		return ScalarCoercion::coerce_int( $total );
+	}
+
+	/**
+	 * The line-item count per contract - the list "Items" column's read. One
+	 * `GROUP BY contract_id` scan of the items table over a page of contract ids,
+	 * returned as a map keyed by EVERY requested id (ids with no items filled with
+	 * 0), so the caller renders a value for every row without a per-row query. Ids
+	 * are de-duplicated and cast to int before binding; an empty request is a no-op.
+	 *
+	 * @param array<int, int> $contract_ids Contract ids to count items for.
+	 * @return array<int, int> Contract id => line-item count, one entry per requested id.
+	 */
+	public function count_items_by_contract( array $contract_ids ): array {
+		// Keep only scalar, positive ids (this is a public entry point): casting an object or
+		// array with intval would warn and collapse to a bogus 0. Array keys de-duplicate.
+		$ids = array();
+		foreach ( $contract_ids as $contract_id ) {
+			if ( ! is_scalar( $contract_id ) ) {
+				continue;
+			}
+			$id = (int) $contract_id;
+			if ( $id > 0 ) {
+				$ids[ $id ] = $id;
+			}
+		}
+		$ids = array_values( $ids );
+
+		$counts = array_fill_keys( $ids, 0 );
+		if ( array() === $ids ) {
+			return $counts;
+		}
+
+		global $wpdb;
+
+		$table        = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+		$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
+
+		// The id placeholders join dynamically, so the sniff sees none in the literal.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT contract_id, COUNT(*) AS total FROM {$table} WHERE contract_id IN ({$placeholders}) GROUP BY contract_id", $ids ), ARRAY_A );
+
+		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+			if ( ! is_array( $row ) ) {
+				continue;
+			}
+			$cid = ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 );
+			if ( array_key_exists( $cid, $counts ) ) {
+				$counts[ $cid ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
+			}
+		}
+
+		return $counts;
+	}
+
 	/**
 	 * Construct a page of list contracts from fetched rows: row-only children, with the
 	 * frozen plan terms batch-loaded in ONE `IN()` read for the whole page - the one
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
index fd19a170632..8367fa76492 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -222,7 +222,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 	public function test_list_hydrates_the_plan_snapshot(): void {
 		$this->sign_up_contract();

-		$contracts = Subscriptions::list( 1 );
+		$contracts = Subscriptions::list( array( 'limit' => 1 ) );
 		$this->assertCount( 1, $contracts );

 		$snapshot = $contracts[0]->get_plan_snapshot();
@@ -244,6 +244,57 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( Contract::class, $contracts[0] );
 	}

+	/**
+	 * @testdox list passes status / sort / search / paging args through to the query.
+	 */
+	public function test_list_passes_query_args_through(): void {
+		$active_low  = $this->seed_list_contract( ContractStatus::ACTIVE, '10.00' );
+		$active_high = $this->seed_list_contract( ContractStatus::ACTIVE, '20.00' );
+		$this->seed_list_contract( ContractStatus::CANCELLED, '30.00' );
+
+		// Status filter + sort compose through the facade.
+		$ids = array_map(
+			static fn ( Contract $c ) => (int) $c->get_id(),
+			Subscriptions::list(
+				array(
+					'status'  => ContractStatus::ACTIVE,
+					'orderby' => 'total',
+					'order'   => 'ASC',
+				)
+			)
+		);
+		$this->assertSame( array( $active_low, $active_high ), $ids );
+
+		// Paging windows the same filtered set.
+		$page = Subscriptions::list(
+			array(
+				'status' => ContractStatus::ACTIVE,
+				'limit'  => 1,
+				'offset' => 1,
+			)
+		);
+		$this->assertCount( 1, $page );
+	}
+
+	/**
+	 * @testdox count_by_status returns a full status map and count honours the same filter.
+	 */
+	public function test_count_by_status_and_count(): void {
+		$this->seed_list_contract( ContractStatus::ACTIVE );
+		$this->seed_list_contract( ContractStatus::ACTIVE );
+		$this->seed_list_contract( ContractStatus::ON_HOLD );
+
+		$by_status = Subscriptions::count_by_status();
+		$this->assertSame( ContractStatus::all(), array_keys( $by_status ) );
+		$this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
+		$this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
+		$this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
+
+		// The grand total and a status-filtered total agree with the map.
+		$this->assertSame( 3, Subscriptions::count() );
+		$this->assertSame( 2, Subscriptions::count( array( 'status' => ContractStatus::ACTIVE ) ) );
+	}
+
 	/**
 	 * @testdox get_history returns the billing cycles newest first.
 	 */
@@ -310,6 +361,29 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
 		$this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
 	}

+	/**
+	 * Seed a bare contract at a status and billing total for the list-query tests,
+	 * returning its id.
+	 *
+	 * @param string $status        Contract status.
+	 * @param string $billing_total Billing total (decimal string).
+	 */
+	private function seed_list_contract( string $status = ContractStatus::ACTIVE, string $billing_total = '19.99' ): int {
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 42,
+				'status'           => $status,
+				'currency'         => 'USD',
+				'selling_plan_id'  => 1,
+				'start_gmt'        => '2026-01-01 00:00:00',
+				'next_payment_gmt' => '2099-02-01 00:00:00',
+				'billing_total'    => $billing_total,
+			)
+		);
+
+		return ( new ContractRepository() )->insert( $contract );
+	}
+
 	/**
 	 * Seed a contract for a customer at a status, returning its id.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index d04f8a4d35a..a0a0c0d6908 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
@@ -226,6 +226,376 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( array( $second, $first ), array_map( static fn ( Contract $c ) => $c->get_id(), $offset ) );
 	}

+	/**
+	 * Insert a contract at a given status with the given list-relevant columns, returning its id.
+	 *
+	 * @param string      $status          Contract status (a ContractStatus value).
+	 * @param int         $customer_id     Owning customer id.
+	 * @param string|null $next_payment    Next-payment GMT string, or null.
+	 * @param string      $billing_total   Billing total (decimal string).
+	 * @param string      $start           Start GMT string.
+	 * @param int|null    $origin_order_id Origin order id, or null.
+	 */
+	private function insert_list_contract(
+		string $status,
+		int $customer_id = 42,
+		?string $next_payment = '2026-07-15 00:00:00',
+		string $billing_total = '19.99',
+		string $start = '2026-06-15 00:00:00',
+		?int $origin_order_id = 1001
+	): int {
+		return $this->sut->insert(
+			Contract::create(
+				array(
+					'customer_id'      => $customer_id,
+					'status'           => $status,
+					'currency'         => 'USD',
+					'selling_plan_id'  => 7,
+					'origin_order_id'  => $origin_order_id,
+					'start_gmt'        => $start,
+					'next_payment_gmt' => $next_payment,
+					'billing_total'    => $billing_total,
+				)
+			)
+		);
+	}
+
+	/**
+	 * The ids returned by a query, in result order.
+	 *
+	 * @param array<string, mixed> $args Query args.
+	 * @return array<int, int>
+	 */
+	private function query_ids( array $args ): array {
+		return array_map( static fn ( Contract $c ) => (int) $c->get_id(), $this->sut->query( $args ) );
+	}
+
+	/**
+	 * @testdox query filters by a valid status and ignores an invalid or empty status.
+	 */
+	public function test_query_filters_by_status(): void {
+		$active    = $this->insert_list_contract( ContractStatus::ACTIVE );
+		$on_hold   = $this->insert_list_contract( ContractStatus::ON_HOLD );
+		$cancelled = $this->insert_list_contract( ContractStatus::CANCELLED );
+
+		$this->assertSame( array( $active ), $this->query_ids( array( 'status' => ContractStatus::ACTIVE ) ) );
+		$this->assertSame( array( $on_hold ), $this->query_ids( array( 'status' => ContractStatus::ON_HOLD ) ) );
+
+		// An unknown status is ignored (not injected into SQL): all rows come back, newest first.
+		$this->assertSame(
+			array( $cancelled, $on_hold, $active ),
+			$this->query_ids( array( 'status' => 'not-a-status' ) )
+		);
+
+		// An empty status is ignored too.
+		$this->assertSame(
+			array( $cancelled, $on_hold, $active ),
+			$this->query_ids( array( 'status' => '' ) )
+		);
+	}
+
+	/**
+	 * @testdox query sorts by a whitelisted column and direction, defaulting to id DESC.
+	 */
+	public function test_query_sorts_by_whitelisted_orderby_and_order(): void {
+		// Distinct totals and next-payment dates so the ordering is unambiguous.
+		$low  = $this->insert_list_contract( ContractStatus::ACTIVE, 42, '2026-09-15 00:00:00', '10.00' );
+		$high = $this->insert_list_contract( ContractStatus::ACTIVE, 42, '2026-07-15 00:00:00', '30.00' );
+		$mid  = $this->insert_list_contract( ContractStatus::ACTIVE, 42, '2026-08-15 00:00:00', '20.00' );
+
+		// total ASC.
+		$this->assertSame(
+			array( $low, $mid, $high ),
+			$this->query_ids(
+				array(
+					'orderby' => 'total',
+					'order'   => 'ASC',
+				)
+			)
+		);
+
+		// total DESC (order defaults to DESC when omitted).
+		$this->assertSame( array( $high, $mid, $low ), $this->query_ids( array( 'orderby' => 'total' ) ) );
+
+		// next_payment maps to next_payment_gmt: ASC is earliest-first.
+		$this->assertSame(
+			array( $high, $mid, $low ),
+			$this->query_ids(
+				array(
+					'orderby' => 'next_payment',
+					'order'   => 'ASC',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox query falls back to id DESC for an unknown orderby or order (never raw SQL).
+	 */
+	public function test_query_falls_back_for_invalid_sort(): void {
+		$first  = $this->insert_list_contract( ContractStatus::ACTIVE );
+		$second = $this->insert_list_contract( ContractStatus::ACTIVE );
+		$third  = $this->insert_list_contract( ContractStatus::ACTIVE );
+
+		// An unknown orderby column falls back to id, and an unknown order to DESC - no SQL error.
+		$this->assertSame(
+			array( $third, $second, $first ),
+			$this->query_ids(
+				array(
+					'orderby' => 'customer_id; DROP TABLE contracts',
+					'order'   => 'sideways',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox query clamps a negative limit or offset instead of emitting invalid SQL.
+	 */
+	public function test_query_clamps_negative_paging(): void {
+		$this->insert_list_contract( ContractStatus::ACTIVE );
+		$this->insert_list_contract( ContractStatus::ACTIVE );
+
+		// A negative limit clamps to 0 (LIMIT 0 -> no rows) rather than "LIMIT -n", which is a SQL error.
+		$this->assertSame( array(), $this->query_ids( array( 'limit' => -5 ) ) );
+
+		// A negative offset clamps to 0, so the page is unaffected and no SQL error is raised.
+		$this->assertCount( 2, $this->query_ids( array( 'offset' => -10 ) ) );
+	}
+
+	/**
+	 * @testdox query search matches by contract id or origin order id for a numeric term.
+	 */
+	public function test_query_search_matches_id_and_origin_order_for_a_numeric_term(): void {
+		$by_id     = $this->insert_list_contract( ContractStatus::ACTIVE, 42, '2026-07-15 00:00:00', '19.99', '2026-06-15 00:00:00', 500 );
+		$by_origin = $this->insert_list_contract( ContractStatus::ACTIVE, 42, '2026-07-15 00:00:00', '19.99', '2026-06-15 00:00:00', 700 );
+
+		// The term equals the first contract's id: it matches by id.
+		$this->assertSame( array( $by_id ), $this->query_ids( array( 'search' => (string) $by_id ) ) );
+
+		// The term equals the second contract's origin order id: it matches by origin_order_id.
+		$this->assertSame( array( $by_origin ), $this->query_ids( array( 'search' => '700' ) ) );
+
+		// A numeric term matching nothing returns no rows.
+		$this->assertSame( array(), $this->query_ids( array( 'search' => '99999999' ) ) );
+	}
+
+	/**
+	 * @testdox query search resolves a non-numeric term to matching customers.
+	 */
+	public function test_query_search_matches_customers_for_a_text_term(): void {
+		$alice = self::factory()->user->create(
+			array(
+				'user_email'   => 'alice@example.test',
+				'display_name' => 'Alice Example',
+			)
+		);
+		$bob   = self::factory()->user->create(
+			array(
+				'user_email'   => 'bob@example.test',
+				'display_name' => 'Bob Example',
+			)
+		);
+		$this->assertIsInt( $alice );
+		$this->assertIsInt( $bob );
+
+		$alice_contract = $this->insert_list_contract( ContractStatus::ACTIVE, (int) $alice );
+		$this->insert_list_contract( ContractStatus::ACTIVE, (int) $bob );
+
+		// The email resolves to Alice's user id, then to her contract.
+		$this->assertSame( array( $alice_contract ), $this->query_ids( array( 'search' => 'alice@example.test' ) ) );
+
+		// A text term matching no user returns no rows (empty customer set -> no rows).
+		$this->assertSame( array(), $this->query_ids( array( 'search' => 'nobody-by-this-name' ) ) );
+	}
+
+	/**
+	 * @testdox query search matches a customer by display name and by login, not only email.
+	 */
+	public function test_query_search_matches_display_name_and_login(): void {
+		$customer = self::factory()->user->create(
+			array(
+				'user_login'   => 'zelda_login',
+				'user_email'   => 'zelda@example.test',
+				'display_name' => 'Zelda Fitzgerald',
+			)
+		);
+		$this->assertIsInt( $customer );
+		$contract = $this->insert_list_contract( ContractStatus::ACTIVE, (int) $customer );
+
+		// The users-table subquery covers display_name and user_login, not just email.
+		$this->assertSame( array( $contract ), $this->query_ids( array( 'search' => 'Fitzgerald' ) ) );
+		$this->assertSame( array( $contract ), $this->query_ids( array( 'search' => 'zelda_login' ) ) );
+	}
+
+	/**
+	 * @testdox query/count customer search keeps every match, past the old 50-user lookup cap.
+	 */
+	public function test_query_search_is_not_capped_at_a_user_limit(): void {
+		// More than the old 50-user get_users() cap, all sharing an email substring, each with a contract.
+		$total = 55;
+		for ( $i = 0; $i < $total; $i++ ) {
+			$customer = self::factory()->user->create( array( 'user_email' => "capsearch{$i}@example.test" ) );
+			$this->assertIsInt( $customer );
+			$this->insert_list_contract( ContractStatus::ACTIVE, (int) $customer );
+		}
+
+		// The users-table subquery matches every customer whose email contains the term - no
+		// truncation - and count() agrees with the full set the page is a window onto.
+		$this->assertSame( $total, $this->sut->count( array( 'search' => 'capsearch' ) ) );
+		$this->assertCount(
+			$total,
+			$this->sut->query(
+				array(
+					'search' => 'capsearch',
+					'limit'  => 100,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox query composes status, search, and sort together.
+	 */
+	public function test_query_composes_status_search_and_sort(): void {
+		$customer = self::factory()->user->create(
+			array(
+				'user_email'   => 'composer@example.test',
+				'display_name' => 'Composer Example',
+			)
+		);
+		$other    = self::factory()->user->create( array( 'user_email' => 'other@example.test' ) );
+		$this->assertIsInt( $customer );
+		$this->assertIsInt( $other );
+
+		$active_low  = $this->insert_list_contract( ContractStatus::ACTIVE, (int) $customer, '2026-07-15 00:00:00', '10.00' );
+		$active_high = $this->insert_list_contract( ContractStatus::ACTIVE, (int) $customer, '2026-07-15 00:00:00', '20.00' );
+		// Same customer, different status - excluded by the status filter.
+		$this->insert_list_contract( ContractStatus::CANCELLED, (int) $customer, '2026-07-15 00:00:00', '30.00' );
+		// A different customer - excluded by the search.
+		$this->insert_list_contract( ContractStatus::ACTIVE, (int) $other, '2026-07-15 00:00:00', '5.00' );
+
+		$this->assertSame(
+			array( $active_low, $active_high ),
+			$this->query_ids(
+				array(
+					'status'  => ContractStatus::ACTIVE,
+					'search'  => 'composer@example.test',
+					'orderby' => 'total',
+					'order'   => 'ASC',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox count_by_status returns every known status, filling absent ones with zero.
+	 */
+	public function test_count_by_status_returns_every_status_filling_zeros(): void {
+		$this->insert_list_contract( ContractStatus::ACTIVE );
+		$this->insert_list_contract( ContractStatus::ACTIVE );
+		$this->insert_list_contract( ContractStatus::ON_HOLD );
+
+		$counts = $this->sut->count_by_status();
+
+		// Every known status is a key, in ContractStatus::all() order, with absent ones 0.
+		$this->assertSame( ContractStatus::all(), array_keys( $counts ) );
+		$this->assertSame( 2, $counts[ ContractStatus::ACTIVE ] );
+		$this->assertSame( 1, $counts[ ContractStatus::ON_HOLD ] );
+		$this->assertSame( 0, $counts[ ContractStatus::PENDING_CANCELLATION ] );
+		$this->assertSame( 0, $counts[ ContractStatus::CANCELLED ] );
+		$this->assertSame( 0, $counts[ ContractStatus::EXPIRED ] );
+	}
+
+	/**
+	 * @testdox count_by_status returns all-zero when there are no contracts.
+	 */
+	public function test_count_by_status_is_all_zero_when_empty(): void {
+		$counts = $this->sut->count_by_status();
+
+		$this->assertSame( ContractStatus::all(), array_keys( $counts ) );
+		$this->assertSame( array( 0, 0, 0, 0, 0 ), array_values( $counts ) );
+	}
+
+	/**
+	 * @testdox count honours the same status + search filter as query, ignoring paging/sort.
+	 */
+	public function test_count_matches_the_query_filter(): void {
+		$customer = self::factory()->user->create( array( 'user_email' => 'counted@example.test' ) );
+		$this->assertIsInt( $customer );
+
+		$this->insert_list_contract( ContractStatus::ACTIVE, (int) $customer );
+		$this->insert_list_contract( ContractStatus::ACTIVE, (int) $customer );
+		$this->insert_list_contract( ContractStatus::ON_HOLD, (int) $customer );
+		$this->insert_list_contract( ContractStatus::ACTIVE, 42, '2026-07-15 00:00:00', '19.99', '2026-06-15 00:00:00', 4242 );
+
+		// No args: the grand total.
+		$this->assertSame( 4, $this->sut->count() );
+
+		// A status filter counts only that status.
+		$this->assertSame( 3, $this->sut->count( array( 'status' => ContractStatus::ACTIVE ) ) );
+		$this->assertSame( 1, $this->sut->count( array( 'status' => ContractStatus::ON_HOLD ) ) );
+
+		// A numeric search counts by id / origin order id.
+		$this->assertSame( 1, $this->sut->count( array( 'search' => '4242' ) ) );
+
+		// A text search counts the matching customer's rows; status composes with it.
+		$this->assertSame( 3, $this->sut->count( array( 'search' => 'counted@example.test' ) ) );
+		$this->assertSame(
+			2,
+			$this->sut->count(
+				array(
+					'search' => 'counted@example.test',
+					'status' => ContractStatus::ACTIVE,
+				)
+			)
+		);
+
+		// Paging and sort args do not change the count.
+		$this->assertSame(
+			4,
+			$this->sut->count(
+				array(
+					'limit'   => 1,
+					'offset'  => 2,
+					'orderby' => 'total',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox count agrees with the number of rows query returns for the same filter.
+	 */
+	public function test_count_agrees_with_query_result_size(): void {
+		$this->insert_list_contract( ContractStatus::ACTIVE );
+		$this->insert_list_contract( ContractStatus::ACTIVE );
+		$this->insert_list_contract( ContractStatus::CANCELLED );
+
+		$args = array( 'status' => ContractStatus::ACTIVE );
+		$this->assertSame( count( $this->sut->query( $args ) ), $this->sut->count( $args ) );
+	}
+
+	/**
+	 * @testdox count_items_by_contract maps every requested id, zero-filling ids with no items.
+	 */
+	public function test_count_items_by_contract_maps_every_requested_id(): void {
+		$with_items = $this->sut->insert( $this->make_contract() ); // Seeds one line item.
+		$no_items   = $this->insert_list_contract( ContractStatus::ACTIVE ); // Bare row, no items.
+		$absent     = 999999; // Never inserted.
+
+		$counts = $this->sut->count_items_by_contract( array( $with_items, $no_items, $absent ) );
+
+		$this->assertSame( 1, $counts[ $with_items ], 'A contract with items reports its line-item count.' );
+		$this->assertSame( 0, $counts[ $no_items ], 'A contract with no items is zero-filled, not absent.' );
+		$this->assertSame( 0, $counts[ $absent ], 'A requested id with no rows is present at zero.' );
+		$this->assertCount( 3, $counts, 'The map carries exactly the requested ids.' );
+
+		// De-duplicates its input and short-circuits an empty request.
+		$this->assertSame( array( $with_items => 1 ), $this->sut->count_items_by_contract( array( $with_items, $with_items ) ) );
+		$this->assertSame( array(), $this->sut->count_items_by_contract( array() ) );
+	}
+
 	/**
 	 * @testdox A manual/admin contract with a null origin order round-trips.
 	 */