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();