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.
*/