Commit c4f3f68bb69 for woocommerce

commit c4f3f68bb696877c24d76f818f89281a5b13cc9e
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Tue Jul 7 15:01:42 2026 +0200

    Add the SellingPlans catalog read facade (#66260)

    - Scope reads at construction: an extension builds one instance with its slugs and calls list_plans() / get_plans() without threading the slug through every call
    - Add an ids filter to PlanRepository::query()/count(); empty or invalid id lists match nothing
    - Consolidate slug filtering on the extension_slugs array arg - one slug unfolds to an equality clause, invalid input fails closed via the named MATCH_NOTHING sentinel
    - Keep applicability consumer-owned: the engine stores and serves the plans catalog only

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-selling-plans-catalog-read-facade b/packages/php/woocommerce-subscriptions-engine/changelog/add-selling-plans-catalog-read-facade
new file mode 100644
index 00000000000..d6083d22dae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-selling-plans-catalog-read-facade
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the Api\SellingPlans catalog read facade (list_plans, get_plans) and an ids filter to PlanRepository::query().
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php
new file mode 100644
index 00000000000..f795ff5526b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * SellingPlans - the engine's public catalog read facade.
+ *
+ * The one surface consumers import to read the plans catalog: list an
+ * extension's active plans and fetch specific plans by id for selection and
+ * display UIs. Which products a plan applies to is consumer-owned - the
+ * engine stores the catalog, not product attachment. The facade hides the
+ * internal `Integration\` repositories behind a stable boundary, so the
+ * internals stay refactorable. Strictly additive-only, like every `Api\`
+ * surface.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public selling-plans catalog read facade.
+ *
+ * Each extension constructs one instance scoped to its own slugs and reuses
+ * it for every read - the slug scope is fixed at construction so call sites
+ * never carry it around. Instances are cheap and hold no state beyond the
+ * scope, so constructing more is harmless. Final: a facade over the engine
+ * internals, not an extension seam.
+ */
+final class SellingPlans {
+
+	/**
+	 * Query limit for plan lookups; high enough that a plan catalog is never
+	 * truncated by the repository's default of 50.
+	 *
+	 * @var int
+	 */
+	private const PLAN_QUERY_LIMIT = 200;
+
+	/**
+	 * Extension slugs this instance reads plans for.
+	 *
+	 * @var array<int, string>
+	 */
+	private $extension_slugs;
+
+	/**
+	 * Scope the facade to the calling extension's slugs.
+	 *
+	 * @param array<int, string> $extension_slugs Extension slugs to read plans for.
+	 */
+	public function __construct( array $extension_slugs ) {
+		$this->extension_slugs = $extension_slugs;
+	}
+
+	/**
+	 * List the scoped extensions' active plans in display order - the read
+	 * behind a plan-selection UI.
+	 *
+	 * @return array<int, Plan> Plans in display order.
+	 */
+	public function list_plans(): array {
+		return ( new PlanRepository() )->query(
+			array(
+				'status'          => Plan::STATUS_ACTIVE,
+				'extension_slugs' => $this->extension_slugs,
+				'limit'           => self::PLAN_QUERY_LIMIT,
+			)
+		);
+	}
+
+	/**
+	 * Fetch the active plans among the given ids owned by the scoped
+	 * extensions, in display order - the read behind rendering a stored plan
+	 * selection.
+	 *
+	 * Ids that are unknown, archived, or owned by an out-of-scope extension
+	 * are simply absent from the result. An empty or invalid id list yields
+	 * an empty array.
+	 *
+	 * @param array<int, int> $plan_ids Plan ids to fetch.
+	 * @return array<int, Plan> Plans in display order.
+	 */
+	public function get_plans( array $plan_ids ): array {
+		return ( new PlanRepository() )->query(
+			array(
+				'status'          => Plan::STATUS_ACTIVE,
+				'extension_slugs' => $this->extension_slugs,
+				'ids'             => $plan_ids,
+				'limit'           => self::PLAN_QUERY_LIMIT,
+			)
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
index 9982bbd1ebb..68961444f34 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -26,6 +26,16 @@ final class PlanRepository {
 	 */
 	private const JSON_COLUMNS = array( 'options', 'billing_policy', 'delivery_policy', 'pricing_policy' );

+	/**
+	 * Always-false WHERE clause: a filter arg that is present but empty or
+	 * invalid must match NOTHING, never fall open to matching everything
+	 * (the WP core / WooCommerce fail-closed posture, e.g. WP_Tax_Query and
+	 * the HPOS OrdersTableFieldQuery force_no_results clause).
+	 *
+	 * @var string
+	 */
+	private const MATCH_NOTHING = '0 = 1';
+
 	/**
 	 * Columns callers may sort by through query().
 	 *
@@ -122,8 +132,13 @@ final class PlanRepository {
 	/**
 	 * Query plans.
 	 *
-	 * Supported args: limit, offset, search, status, extension_slug, orderby,
-	 * order. Results default to manual order, oldest id as a stable tiebreaker.
+	 * Supported args: limit, offset, search, status, extension_slugs, ids,
+	 * orderby, order. `extension_slugs` filters by owning extension: a list
+	 * of slugs (a single-slug list unfolds to an equality match) or
+	 * `array( 'any' )` to skip the scope. `ids` filters to plans whose id is
+	 * in the given int list; it composes with the other filters and is
+	 * honored by count(). Results default to manual order, oldest id as a
+	 * stable tiebreaker.
 	 *
 	 * @param array<string, mixed> $args Query args.
 	 * @return array<int, Plan>
@@ -339,15 +354,6 @@ final class PlanRepository {
 			$params[]  = $status;
 		}

-		if ( array_key_exists( 'extension_slug', $args ) ) {
-			if ( self::is_valid_extension_slug( $args['extension_slug'] ) ) {
-				$clauses[] = 'extension_slug = %s';
-				$params[]  = $args['extension_slug'];
-			} else {
-				$clauses[] = '0 = 1';
-			}
-		}
-
 		if ( array_key_exists( 'extension_slugs', $args ) && null !== $args['extension_slugs'] ) {
 			$are_extension_slugs_valid = false;

@@ -368,14 +374,49 @@ final class PlanRepository {
 						$are_extension_slugs_valid = true;

 						$extension_slugs = array_values( $valid_slugs );
-						$clauses[]       = 'extension_slug IN (' . implode( ',', array_fill( 0, count( $extension_slugs ), '%s' ) ) . ')';
-						$params          = array_merge( $params, $extension_slugs );
+						if ( 1 === count( $extension_slugs ) ) {
+							$clauses[] = 'extension_slug = %s';
+							$params[]  = $extension_slugs[0];
+						} else {
+							$clauses[] = 'extension_slug IN (' . implode( ',', array_fill( 0, count( $extension_slugs ), '%s' ) ) . ')';
+							$params    = array_merge( $params, $extension_slugs );
+						}
 					}
 				}
 			}

 			if ( ! $are_extension_slugs_valid ) {
-				$clauses[] = '0 = 1';
+				$clauses[] = self::MATCH_NOTHING;
+			}
+		}
+
+		if ( array_key_exists( 'ids', $args ) && null !== $args['ids'] ) {
+			$are_ids_valid = false;
+
+			if ( is_array( $args['ids'] ) && array() !== $args['ids'] ) {
+				$ids       = array();
+				$all_valid = true;
+				foreach ( array_values( $args['ids'] ) as $possible_id ) {
+					$plan_id = ScalarCoercion::coerce_int( $possible_id );
+					if ( $plan_id <= 0 ) {
+						$all_valid = false;
+						break;
+					}
+					$ids[ $plan_id ] = $plan_id;
+				}
+
+				// Require all ids to be positive ints before running the query.
+				if ( $all_valid ) {
+					$are_ids_valid = true;
+
+					$ids       = array_values( $ids );
+					$clauses[] = 'id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')';
+					$params    = array_merge( $params, $ids );
+				}
+			}
+
+			if ( ! $are_ids_valid ) {
+				$clauses[] = self::MATCH_NOTHING;
 			}
 		}

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php
new file mode 100644
index 00000000000..6635a8f998a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Integration tests for the SellingPlans facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\SellingPlans;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\SellingPlans
+ */
+class SellingPlansTest extends EngineIntegrationTestCase {
+
+	private const SLUG = 'lite';
+
+	/**
+	 * Insert a plan group.
+	 *
+	 * @param string $merchant_code  Merchant code.
+	 * @param string $extension_slug Owning extension slug.
+	 */
+	private function make_group( string $merchant_code, string $extension_slug = self::SLUG ): int {
+		return ( new PlanGroupRepository() )->insert(
+			PlanGroup::create(
+				array(
+					'name'           => 'Group ' . $merchant_code,
+					'merchant_code'  => $merchant_code,
+					'extension_slug' => $extension_slug,
+				)
+			)
+		);
+	}
+
+	/**
+	 * Insert a plan.
+	 *
+	 * @param int                  $group_id  Parent group id.
+	 * @param string               $name      Plan name.
+	 * @param array<string, mixed> $overrides Attribute overrides.
+	 */
+	private function make_plan( int $group_id, string $name, array $overrides = array() ): int {
+		return ( new PlanRepository() )->insert(
+			Plan::create(
+				$group_id,
+				array_merge(
+					array(
+						'name'           => $name,
+						'billing_policy' => BillingPolicy::from_array(
+							array(
+								'period'   => 'month',
+								'interval' => 1,
+							)
+						),
+						'extension_slug' => self::SLUG,
+					),
+					$overrides
+				)
+			)
+		);
+	}
+
+	/**
+	 * Map plans to their ids.
+	 *
+	 * @param array<int, Plan> $plans Plans to map.
+	 * @return array<int, int|null>
+	 */
+	private static function plan_ids( array $plans ): array {
+		return array_map(
+			static function ( Plan $plan ): ?int {
+				return $plan->get_id();
+			},
+			$plans
+		);
+	}
+
+	public function test_list_plans_excludes_archived_and_foreign_slug_plans(): void {
+		$group_id = $this->make_group( 'listing' );
+
+		$second_id = $this->make_plan( $group_id, 'Second', array( 'sort_order' => 2 ) );
+		$first_id  = $this->make_plan( $group_id, 'First', array( 'sort_order' => 1 ) );
+		$this->make_plan( $group_id, 'Archived', array( 'status' => Plan::STATUS_ARCHIVED ) );
+		$this->make_plan( $group_id, 'Foreign', array( 'extension_slug' => 'other-extension' ) );
+
+		$plans = ( new SellingPlans( array( self::SLUG ) ) )->list_plans();
+
+		$this->assertSame( array( $first_id, $second_id ), self::plan_ids( $plans ) );
+	}
+
+	public function test_get_plans_returns_active_owned_plans_in_display_order(): void {
+		$group_id = $this->make_group( 'fetching' );
+
+		$second_id   = $this->make_plan( $group_id, 'Second', array( 'sort_order' => 2 ) );
+		$first_id    = $this->make_plan( $group_id, 'First', array( 'sort_order' => 1 ) );
+		$excluded_id = $this->make_plan( $group_id, 'Excluded', array( 'sort_order' => 3 ) );
+		$archived_id = $this->make_plan( $group_id, 'Archived', array( 'status' => Plan::STATUS_ARCHIVED ) );
+		$foreign_id  = $this->make_plan( $group_id, 'Foreign', array( 'extension_slug' => 'other-extension' ) );
+
+		$catalog = new SellingPlans( array( self::SLUG ) );
+
+		$plans = $catalog->get_plans( array( $second_id, $first_id, $archived_id, $foreign_id, 999999 ) );
+
+		// Display order regardless of request order; archived, foreign, and unknown ids are absent.
+		$this->assertSame( array( $first_id, $second_id ), self::plan_ids( $plans ) );
+		$this->assertNotContains( $excluded_id, self::plan_ids( $plans ) );
+	}
+
+	public function test_get_plans_empty_or_invalid_ids_yield_an_empty_array(): void {
+		$group_id = $this->make_group( 'empty-ids' );
+		$plan_id  = $this->make_plan( $group_id, 'Plan' );
+
+		$catalog = new SellingPlans( array( self::SLUG ) );
+
+		// Non-int junk coverage lives in PlanRepositoryTest; the facade takes int ids.
+		$this->assertSame( array(), $catalog->get_plans( array() ) );
+		$this->assertSame( array(), $catalog->get_plans( array( $plan_id, 0 ) ) );
+	}
+
+	public function test_two_slug_instance_reads_across_both_slugs(): void {
+		$lite_group_id  = $this->make_group( 'two-slug-lite' );
+		$other_group_id = $this->make_group( 'two-slug-other', 'other-extension' );
+
+		$lite_id    = $this->make_plan( $lite_group_id, 'Lite plan', array( 'sort_order' => 1 ) );
+		$other_id   = $this->make_plan(
+			$other_group_id,
+			'Other plan',
+			array(
+				'sort_order'     => 2,
+				'extension_slug' => 'other-extension',
+			)
+		);
+		$foreign_id = $this->make_plan(
+			$lite_group_id,
+			'Foreign',
+			array(
+				'sort_order'     => 3,
+				'extension_slug' => 'third-extension',
+			)
+		);
+
+		$catalog = new SellingPlans( array( self::SLUG, 'other-extension' ) );
+
+		$this->assertSame( array( $lite_id, $other_id ), self::plan_ids( $catalog->list_plans() ) );
+		$this->assertSame( array( $lite_id, $other_id ), self::plan_ids( $catalog->get_plans( array( $other_id, $lite_id, $foreign_id ) ) ) );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
index dd03e0f5c3f..977af3a7cf3 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
@@ -306,13 +306,13 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		$expected_id = $this->make_plan( $repo, $group_id, $search . ' plan', 'lite' );

 		$query_args = array(
-			'extension_slug' => 'lite',
-			'status'         => Plan::STATUS_ACTIVE,
-			'search'         => $search,
-			'orderby'        => 'id',
-			'order'          => 'asc',
-			'limit'          => 10,
-			'offset'         => 0,
+			'extension_slugs' => array( 'lite' ),
+			'status'          => Plan::STATUS_ACTIVE,
+			'search'          => $search,
+			'orderby'         => 'id',
+			'order'           => 'asc',
+			'limit'           => 10,
+			'offset'          => 0,
 		);
 		$plans      = $repo->query( $query_args );

@@ -338,12 +338,40 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {

 		$this->assertNull( $repo->find( $id, '' ) );
 		$this->assertNull( $repo->find( $id, 'bad slug' ) );
-		$this->assertCount( 0, $repo->query( array( 'extension_slug' => '' ) ) );
-		$this->assertSame( 0, $repo->count( array( 'extension_slug' => '' ) ) );
+		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array() ) ) );
+		$this->assertSame( 0, $repo->count( array( 'extension_slugs' => array() ) ) );
+		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array( '' ) ) ) );
+		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => 'not-an-array' ) ) );
 		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array( 'lite', '' ) ) ) );
 		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array( 'bad slug' ) ) ) );
 	}

+	public function test_query_extension_slugs_filters_by_single_and_multiple_slugs(): void {
+		$group_id = $this->make_group();
+		$repo     = new PlanRepository();
+
+		$lite_id  = $this->make_plan( $repo, $group_id, 'Lite plan', 'lite', 1 );
+		$other_id = $this->make_plan( $repo, $group_id, 'Other plan', 'other-extension', 2 );
+
+		$single = $repo->query( array( 'extension_slugs' => array( 'lite' ) ) );
+		$this->assertSame( array( $lite_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $single ) );
+		$this->assertSame( 1, $repo->count( array( 'extension_slugs' => array( 'lite' ) ) ) );
+
+		$both = $repo->query( array( 'extension_slugs' => array( 'lite', 'other-extension' ) ) );
+		$this->assertSame( array( $lite_id, $other_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $both ) );
+	}
+
+	public function test_query_singular_extension_slug_arg_is_unknown_and_ignored(): void {
+		$group_id = $this->make_group();
+		$repo     = new PlanRepository();
+
+		$plan_id = $this->make_plan( $repo, $group_id, 'Scoped', 'lite' );
+
+		$plans = $repo->query( array( 'extension_slug' => 'other-extension' ) );
+		$this->assertSame( array( $plan_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans ) );
+		$this->assertSame( 1, $repo->count( array( 'extension_slug' => '' ) ) );
+	}
+
 	public function test_reorder_fails_before_updates_when_an_id_is_missing_or_outside_extension(): void {
 		$group_id = $this->make_group();
 		$repo     = new PlanRepository();
@@ -383,6 +411,69 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 2, $other->get_sort_order() );
 	}

+	public function test_query_ids_returns_only_those_plans(): void {
+		$repo     = new PlanRepository();
+		$group_id = $this->make_group();
+
+		$first_plan_id  = $this->make_plan( $repo, $group_id, 'First', 'lite', 1 );
+		$second_plan_id = $this->make_plan( $repo, $group_id, 'Second', 'lite', 2 );
+		$this->make_plan( $repo, $group_id, 'Third', 'lite', 3 );
+
+		$plans = $repo->query( array( 'ids' => array( $first_plan_id, $second_plan_id ) ) );
+
+		$this->assertSame( array( $first_plan_id, $second_plan_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans ) );
+		$this->assertSame( 2, $repo->count( array( 'ids' => array( $first_plan_id, $second_plan_id ) ) ) );
+	}
+
+	public function test_query_ids_composes_with_status_and_extension_slugs(): void {
+		$repo     = new PlanRepository();
+		$group_id = $this->make_group();
+
+		$active_id  = $this->make_plan( $repo, $group_id, 'Active lite', 'lite', 1 );
+		$foreign_id = $this->make_plan( $repo, $group_id, 'Other extension', 'other-extension', 2 );
+
+		$archived = $repo->find( $this->make_plan( $repo, $group_id, 'Archived lite', 'lite', 3 ) );
+		$this->assertInstanceOf( Plan::class, $archived );
+		$archived->set_status( Plan::STATUS_ARCHIVED );
+		$this->assertTrue( $repo->update( $archived ) );
+
+		$plans = $repo->query(
+			array(
+				'status'          => Plan::STATUS_ACTIVE,
+				'extension_slugs' => array( 'lite' ),
+				'ids'             => array( $active_id, $foreign_id, (int) $archived->get_id() ),
+			)
+		);
+
+		$this->assertCount( 1, $plans );
+		$this->assertSame( $active_id, $plans[0]->get_id() );
+	}
+
+	public function test_query_empty_or_invalid_ids_match_nothing(): void {
+		$repo     = new PlanRepository();
+		$group_id = $this->make_group();
+		$plan_id  = $this->make_plan( $repo, $group_id, 'Plan', 'lite' );
+
+		$this->assertCount( 0, $repo->query( array( 'ids' => array() ) ) );
+		$this->assertSame( 0, $repo->count( array( 'ids' => array() ) ) );
+		$this->assertCount( 0, $repo->query( array( 'ids' => array( $plan_id, 0 ) ) ) );
+		$this->assertCount( 0, $repo->query( array( 'ids' => array( 'junk' ) ) ) );
+		$this->assertCount( 0, $repo->query( array( 'ids' => 'not-an-array' ) ) );
+	}
+
+	public function test_query_null_ids_behaves_as_arg_absent(): void {
+		$repo     = new PlanRepository();
+		$group_id = $this->make_group();
+
+		$first_plan_id  = $this->make_plan( $repo, $group_id, 'First', 'lite', 1 );
+		$second_plan_id = $this->make_plan( $repo, $group_id, 'Second', 'lite', 2 );
+
+		$plans = $repo->query( array( 'ids' => null ) );
+
+		$this->assertSame( array( $first_plan_id, $second_plan_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans ) );
+		$this->assertSame( 2, $repo->count( array( 'ids' => null ) ) );
+	}
+
 	public function test_delete_removes_the_row(): void {
 		$group_id = $this->make_group();
 		$repo     = new PlanRepository();