Commit f5561059a8f for woocommerce

commit f5561059a8f407f4016761e9e2b6d4a2236fb22d
Author: daledupreez <dale@automattic.com>
Date:   Fri Jun 26 18:08:43 2026 +0200

    Add plans REST API for subscriptions engine (#65958)

    * Add REST API; add sort_order and status to plans
    * Use 0.x version for Schema during development
    * Shift REST API shape to require extension_slug
    * Remove BOGO references/handling from engine
    * Move extension_slug to a query parameter; allow multiple slugs
    * Move REST API files; move away from trait for permissions
    * Remove AdminPermission trait
    * Fix misses in rename
    * Remove definitions API
    * Changelog
    * Address linting issues
    * Add PHPStan ignore for use of str_contains()
    * Use strpos() to keep PHPStan happy
    * Add stricter validation for extension slugs
    * Reject duplicate IDs for reorder requests
    * Defer plan group name update
    * Create and validate plan and plan group before saving
    * Remove lingering BOGO code
    * Add stricter reorder and extension slug validation
    * Remove some more BOGO code
    * Validate cycle values for PricingPolicy
    * Improve handling of empty string in PlanRepository->find()
    * Add status to supported order by columns
    * Reject 0 and negative integers in normalize_cycle()
    * Add integration test for status ordering
    * Update else if to elseif for linter

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-rest-api-for-subscriptions-engine-plans b/packages/php/woocommerce-subscriptions-engine/changelog/add-rest-api-for-subscriptions-engine-plans
new file mode 100644
index 00000000000..6952f207243
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-rest-api-for-subscriptions-engine-plans
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Implement Plans REST API
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
new file mode 100644
index 00000000000..8aacb1c1eb6
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -0,0 +1,806 @@
+<?php
+/**
+ * REST controller for subscription engine plans.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
+use InvalidArgumentException;
+use Throwable;
+use WP_Error;
+use WP_REST_Controller;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Plans REST controller.
+ */
+final class PlansController extends WP_REST_Controller {
+
+	use ScalarCoercion;
+
+	private const REST_NAMESPACE = 'wc/v3';
+
+	private const REST_BASE = 'subscriptions-engine/plans';
+
+	private const MAX_PER_PAGE = 100;
+
+	private const DEFAULT_PER_PAGE = 20;
+
+	/**
+	 * Plans repository.
+	 *
+	 * @var PlanRepository
+	 */
+	private $plan_repository;
+
+	/**
+	 * Plan groups repository.
+	 *
+	 * @var PlanGroupRepository
+	 */
+	private $plan_group_repository;
+
+	/**
+	 * REST permissions.
+	 *
+	 * @var RESTPermissions
+	 */
+	private $rest_permissions;
+
+	/**
+	 * Construct the controller.
+	 *
+	 * @param PlanRepository|null      $plan_repository       Plans repository.
+	 * @param PlanGroupRepository|null $plan_group_repository Plan groups repository.
+	 * @param RESTPermissions|null     $rest_permissions      REST permissions.
+	 */
+	public function __construct( ?PlanRepository $plan_repository = null, ?PlanGroupRepository $plan_group_repository = null, ?RESTPermissions $rest_permissions = null ) {
+		$this->namespace             = self::REST_NAMESPACE;
+		$this->rest_base             = self::REST_BASE;
+		$this->plan_repository       = $plan_repository ?? new PlanRepository();
+		$this->plan_group_repository = $plan_group_repository ?? new PlanGroupRepository();
+		$this->rest_permissions      = $rest_permissions ?? new RESTPermissions();
+	}
+
+	/**
+	 * Wire route registration.
+	 */
+	public static function register_hooks(): void {
+		add_action(
+			'rest_api_init',
+			static function (): void {
+				( new self() )->register_routes();
+			}
+		);
+	}
+
+	/**
+	 * Register routes.
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			self::REST_NAMESPACE,
+			'/' . self::REST_BASE,
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_items' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+					'args'                => array(
+						'extension_slug' => array(
+							'description' => __( 'Extension slug or comma-separated list of slugs for the plan query. Use "any" to query all slugs.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => true,
+						),
+						'page'           => array(
+							'description' => __( 'Page number for the plan query.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'integer',
+							'required'    => false,
+						),
+						'per_page'       => array(
+							'description' => __( 'Number of plans per page for the plan query.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'integer',
+							'required'    => false,
+							'default'     => self::DEFAULT_PER_PAGE,
+						),
+						'search'         => array(
+							'description' => __( 'Search term for the plan query.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => false,
+						),
+						'status'         => array(
+							'description' => __( 'Status of the plans to query.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => false,
+							'enum'        => array( Plan::STATUS_ACTIVE, Plan::STATUS_ARCHIVED ),
+						),
+						'orderby'        => array(
+							'description' => __( 'Order by field for the plan query.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => false,
+							'enum'        => array( 'id', 'name', 'status', 'sort_order' ),
+							'default'     => 'sort_order',
+						),
+						'order'          => array(
+							'description' => __( 'Order direction for the plan query.', 'woocommerce-subscriptions-engine' ),
+							'type'        => 'string',
+							'required'    => false,
+							'enum'        => array( 'asc', 'desc' ),
+							'default'     => 'asc',
+						),
+					),
+				),
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'create_item' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+				),
+				'schema' => array( $this, 'get_public_item_schema' ),
+			)
+		);
+
+		register_rest_route(
+			self::REST_NAMESPACE,
+			'/' . self::REST_BASE . '/reorder',
+			array(
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'reorder_items' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+			)
+		);
+
+		register_rest_route(
+			self::REST_NAMESPACE,
+			'/' . self::REST_BASE . '/(?P<id>[\d]+)',
+			array(
+				'args'   => array(
+					'id' => array(
+						'description' => __( 'Unique identifier for the plan.', 'woocommerce-subscriptions-engine' ),
+						'type'        => 'integer',
+					),
+				),
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_item' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+				array(
+					'methods'             => 'PATCH',
+					'callback'            => array( $this, 'update_item' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+				'schema' => array( $this, 'get_public_item_schema' ),
+			)
+		);
+	}
+
+	/**
+	 * Permission callback for all management routes.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return true|WP_Error
+	 */
+	public function permissions_check( $request ) {
+		return $this->rest_permissions->require_admin_permission();
+	}
+
+	/**
+	 * Get a paginated plan list.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function get_items( $request ) {
+		$extension_slugs = $this->get_multiple_extension_slugs( $request );
+		if ( $extension_slugs instanceof WP_Error ) {
+			return $extension_slugs;
+		}
+
+		$page     = max( 1, self::coerce_int( $request->get_param( 'page' ), 1 ) );
+		$per_page = $this->resolve_per_page( $request );
+		$args     = array(
+			'limit'           => $per_page,
+			'offset'          => ( $page - 1 ) * $per_page,
+			'extension_slugs' => $extension_slugs,
+		);
+
+		foreach ( array( 'search', 'status', 'orderby', 'order' ) as $key ) {
+			$value = $request->get_param( $key );
+			if ( null !== $value && '' !== $value ) {
+				$args[ $key ] = $value;
+			}
+		}
+
+		$plans = $this->plan_repository->query( $args );
+		$total = $this->plan_repository->count( $args );
+
+		$response = new WP_REST_Response(
+			array_map(
+				function ( Plan $plan ) use ( $request ): array {
+					$prepared = $this->prepare_response_for_collection(
+						$this->prepare_item_for_response( $plan, $request )
+					);
+
+					return is_array( $prepared ) ? $prepared : array();
+				},
+				$plans
+			)
+		);
+		$response->header( 'X-WP-Total', (string) $total );
+		$response->header( 'X-WP-TotalPages', (string) ( 0 === $per_page ? 0 : (int) ceil( $total / $per_page ) ) );
+
+		return $response;
+	}
+
+	/**
+	 * Get one plan.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function get_item( $request ) {
+		$extension_slug = $this->get_single_extension_slug( $request );
+		if ( $extension_slug instanceof WP_Error ) {
+			return $extension_slug;
+		}
+
+		$plan = $this->plan_repository->find( self::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
+		if ( ! $plan instanceof Plan ) {
+			return $this->not_found_error();
+		}
+
+		return rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
+	}
+
+	/**
+	 * Create one global plan.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function create_item( $request ) {
+		$extension_slug = $this->get_single_extension_slug( $request );
+		if ( $extension_slug instanceof WP_Error ) {
+			return $extension_slug;
+		}
+
+		$name = $this->string_param( $request, 'name' );
+		if ( '' === $name ) {
+			return $this->invalid_error( __( 'Plan name is required.', 'woocommerce-subscriptions-engine' ) );
+		}
+
+		$billing_policy = $request->get_param( 'billing_policy' );
+		if ( ! is_array( $billing_policy ) ) {
+			return $this->invalid_error( __( 'billing_policy is required.', 'woocommerce-subscriptions-engine' ) );
+		}
+
+		try {
+			$billing_policy = $this->associative_array( $billing_policy, 'billing_policy must be an object.' );
+			$plan_args      = array(
+				'name'           => $name,
+				'description'    => $this->nullable_string_param( $request, 'description' ),
+				'options'        => array(),
+				'billing_policy' => BillingPolicy::from_array( $billing_policy ),
+				'pricing_policy' => $this->pricing_policy_from_param( $request->get_param( 'pricing_policy' ), null ),
+				'category'       => $this->string_param( $request, 'category', Plan::DEFAULT_CATEGORY ),
+				'status'         => $this->string_param( $request, 'status', Plan::STATUS_ACTIVE ),
+				'sort_order'     => self::coerce_int( $request->get_param( 'sort_order' ) ),
+				'extension_slug' => $extension_slug,
+			);
+			Plan::create( 0, $plan_args );
+
+			$group    = PlanGroup::create(
+				array(
+					'name'            => $name,
+					'options_display' => array(),
+					'extension_slug'  => $extension_slug,
+				)
+			);
+			$group_id = $this->plan_group_repository->insert( $group );
+
+			$plan = Plan::create(
+				$group_id,
+				$plan_args
+			);
+			$this->plan_repository->insert( $plan );
+		} catch ( Throwable $e ) {
+			return $this->invalid_error( $e->getMessage() );
+		}
+
+		$response = rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
+		$response->set_status( 201 );
+
+		return $response;
+	}
+
+	/**
+	 * Partially update a plan.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function update_item( $request ) {
+		$extension_slug = $this->get_single_extension_slug( $request );
+		if ( $extension_slug instanceof WP_Error ) {
+			return $extension_slug;
+		}
+
+		$plan = $this->plan_repository->find( self::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
+		if ( ! $plan instanceof Plan ) {
+			return $this->not_found_error();
+		}
+
+		try {
+			$sync_group_name = null;
+			if ( $request->has_param( 'name' ) ) {
+				$name = $this->string_param( $request, 'name' );
+				if ( '' === $name ) {
+					return $this->invalid_error( __( 'Plan name is required.', 'woocommerce-subscriptions-engine' ) );
+				}
+				$plan->set_name( $name );
+				$sync_group_name = $name;
+			}
+
+			if ( $request->has_param( 'description' ) ) {
+				$plan->set_description( $this->nullable_string_param( $request, 'description' ) );
+			}
+
+			if ( $request->has_param( 'billing_policy' ) ) {
+				$billing_policy = $request->get_param( 'billing_policy' );
+				if ( ! is_array( $billing_policy ) ) {
+					return $this->invalid_error( __( 'billing_policy must be an object.', 'woocommerce-subscriptions-engine' ) );
+				}
+				$billing_policy = $this->associative_array( $billing_policy, 'billing_policy must be an object.' );
+				$plan->set_billing_policy(
+					BillingPolicy::from_array(
+						array_merge( $plan->get_billing_policy()->to_array(), $billing_policy )
+					)
+				);
+			}
+
+			if ( $request->has_param( 'pricing_policy' ) ) {
+				$plan->set_pricing_policy(
+					$this->pricing_policy_from_param( $request->get_param( 'pricing_policy' ), $plan->get_pricing_policy() )
+				);
+			}
+
+			if ( $request->has_param( 'status' ) ) {
+				$plan->set_status( $this->string_param( $request, 'status', Plan::STATUS_ACTIVE ) );
+			}
+
+			if ( $request->has_param( 'sort_order' ) ) {
+				$plan->set_sort_order( self::coerce_int( $request->get_param( 'sort_order' ) ) );
+			}
+
+			if ( null !== $sync_group_name ) {
+				$this->sync_group_name( $plan, $sync_group_name );
+			}
+			$this->plan_repository->update( $plan );
+		} catch ( Throwable $e ) {
+			return $this->invalid_error( $e->getMessage() );
+		}
+
+		return rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
+	}
+
+	/**
+	 * Reorder plans.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function reorder_items( $request ) {
+		$extension_slug = $this->get_single_extension_slug( $request );
+		if ( $extension_slug instanceof WP_Error ) {
+			return $extension_slug;
+		}
+
+		$ids = $request->get_param( 'ids' );
+		if ( ! is_array( $ids ) ) {
+			return $this->invalid_error( __( 'ids must be an array of plan ids.', 'woocommerce-subscriptions-engine' ) );
+		}
+
+		$sort_order_by_id = array();
+		$response_ids     = array();
+		foreach ( array_values( $ids ) as $index => $raw_id ) {
+			$id = self::coerce_nullable_int( $raw_id );
+			if ( null === $id || $id <= 0 ) {
+				return $this->invalid_error( __( 'ids must contain only positive integers.', 'woocommerce-subscriptions-engine' ) );
+			}
+			if ( isset( $sort_order_by_id[ $id ] ) ) {
+				return $this->invalid_error( __( 'ids must not contain duplicate plan ids.', 'woocommerce-subscriptions-engine' ) );
+			}
+			$sort_order_by_id[ $id ] = $index;
+			$response_ids[]          = $id;
+		}
+
+		if ( ! $this->plan_repository->reorder( $extension_slug, $sort_order_by_id ) ) {
+			return new WP_Error(
+				'woocommerce_subscriptions_engine_reorder_failed',
+				__( 'Plan reorder failed.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return rest_ensure_response( array( 'ids' => $response_ids ) );
+	}
+
+	/**
+	 * Serialize a plan.
+	 *
+	 * @param Plan            $item    Plan.
+	 * @param WP_REST_Request $request Request.
+	 * @return WP_REST_Response
+	 */
+	public function prepare_item_for_response( $item, $request ) {
+		$pricing = $item->get_pricing_policy();
+		$group   = $this->plan_group_repository->find( $item->get_group_id() );
+
+		$data = array(
+			'id'             => $item->get_id(),
+			'name'           => $item->get_name(),
+			'description'    => $item->get_description(),
+			'scope'          => 'global',
+			'status'         => $item->get_status(),
+			'sort_order'     => $item->get_sort_order(),
+			'extension_slug' => $item->get_extension_slug(),
+			'billing_policy' => $item->get_billing_policy()->to_array(),
+			'pricing_policy' => null !== $pricing ? $pricing->to_array() : null,
+			'group'          => $group instanceof PlanGroup
+				? array(
+					'id'              => $group->get_id(),
+					'name'            => $group->get_name(),
+					'options_display' => $group->get_options_display(),
+				)
+				: null,
+		);
+
+		$context = self::coerce_string( $request->get_param( 'context' ), 'view' );
+		$context = '' !== $context ? $context : 'view';
+		$data    = $this->add_additional_fields_to_object( $data, $request );
+		$data    = $this->filter_response_by_context( $data, $context );
+
+		return rest_ensure_response( $data );
+	}
+
+	/**
+	 * Get collection params.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function get_collection_params(): array {
+		return array(
+			'page'     => array(
+				'description'       => __( 'Current page of the collection.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'integer',
+				'default'           => 1,
+				'minimum'           => 1,
+				'sanitize_callback' => 'absint',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+			'per_page' => array(
+				'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'integer',
+				'default'           => self::DEFAULT_PER_PAGE,
+				'minimum'           => 1,
+				'maximum'           => self::MAX_PER_PAGE,
+				'sanitize_callback' => 'absint',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+			'search'   => array(
+				'description'       => __( 'Search term.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'string',
+				'sanitize_callback' => 'sanitize_text_field',
+			),
+			'status'   => array(
+				'description'       => __( 'Limit result set to plans with a status.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'string',
+				'enum'              => Plan::ALLOWED_STATUSES,
+				'sanitize_callback' => 'sanitize_key',
+			),
+			'orderby'  => array(
+				'description'       => __( 'Sort collection by object attribute.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'string',
+				'default'           => 'sort_order',
+				'enum'              => array( 'id', 'name', 'sort_order', 'date_created_gmt', 'date_updated_gmt' ),
+				'sanitize_callback' => 'sanitize_key',
+			),
+			'order'    => array(
+				'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce-subscriptions-engine' ),
+				'type'              => 'string',
+				'default'           => 'asc',
+				'enum'              => array( 'asc', 'desc' ),
+				'sanitize_callback' => 'sanitize_key',
+			),
+			'context'  => $this->get_context_param( array( 'default' => 'view' ) ),
+		);
+	}
+
+	/**
+	 * Get item schema.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function get_item_schema(): array {
+		if ( $this->schema ) {
+			return $this->add_additional_fields_schema( $this->schema );
+		}
+
+		$this->schema = array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => 'subscription_engine_plan',
+			'type'       => 'object',
+			'properties' => array(
+				'id'             => array(
+					'description' => __( 'Unique identifier for the plan.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'integer',
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'name'           => array(
+					'description' => __( 'Display name.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
+				'description'    => array(
+					'description' => __( 'Optional description.', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'string', 'null' ),
+					'context'     => array( 'view', 'edit' ),
+				),
+				'scope'          => array(
+					'description' => __( 'Plan scope.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'string',
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'status'         => array(
+					'description' => __( 'Plan status.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'string',
+					'enum'        => Plan::ALLOWED_STATUSES,
+					'context'     => array( 'view', 'edit' ),
+				),
+				'sort_order'     => array(
+					'description' => __( 'Manual sort order.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'integer',
+					'context'     => array( 'view', 'edit' ),
+				),
+				'extension_slug' => array(
+					'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'string', 'null' ),
+					'context'     => array( 'view', 'edit' ),
+				),
+				'billing_policy' => array(
+					'description' => __( 'Billing policy.', 'woocommerce-subscriptions-engine' ),
+					'type'        => 'object',
+					'context'     => array( 'view', 'edit' ),
+				),
+				'pricing_policy' => array(
+					'description' => __( 'Pricing policy.', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'object', 'null' ),
+					'context'     => array( 'view', 'edit' ),
+				),
+				'group'          => array(
+					'description' => __( 'Plan group.', 'woocommerce-subscriptions-engine' ),
+					'type'        => array( 'object', 'null' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+			),
+		);
+
+		return $this->add_additional_fields_schema( $this->schema );
+	}
+
+	/**
+	 * Resolve per_page.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 */
+	private function resolve_per_page( WP_REST_Request $request ): int {
+		$value = self::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
+		if ( $value < 1 ) {
+			return self::DEFAULT_PER_PAGE;
+		}
+
+		return min( $value, self::MAX_PER_PAGE );
+	}
+
+	/**
+	 * Build a pricing policy from a request param, preserving omitted existing keys.
+	 *
+	 * @param mixed              $value    Request value.
+	 * @param PricingPolicy|null $existing Existing policy.
+	 * @return PricingPolicy|null
+	 * @throws InvalidArgumentException If the param shape is invalid.
+	 */
+	private function pricing_policy_from_param( $value, ?PricingPolicy $existing ): ?PricingPolicy {
+		if ( null === $value ) {
+			return null;
+		}
+
+		if ( ! is_array( $value ) ) {
+			throw new InvalidArgumentException( 'pricing_policy must be an object or null.' );
+		}
+
+		$value = $this->associative_array( $value, 'pricing_policy must be an object or null.' );
+		$data  = null !== $existing ? $existing->to_array() : array();
+		if ( array_key_exists( 'policies', $value ) ) {
+			$data['policies'] = $value['policies'];
+		}
+		if ( array_key_exists( 'one_time_fees', $value ) ) {
+			$data['one_time_fees'] = $value['one_time_fees'];
+		}
+
+		return PricingPolicy::from_array( $data );
+	}
+
+	/**
+	 * Sync a one-plan group's display name with the plan name.
+	 *
+	 * @param Plan   $plan Plan.
+	 * @param string $name New name.
+	 */
+	private function sync_group_name( Plan $plan, string $name ): void {
+		$group = $this->plan_group_repository->find( $plan->get_group_id() );
+		if ( ! $group instanceof PlanGroup ) {
+			return;
+		}
+
+		$group->set_name( $name );
+		$this->plan_group_repository->update( $group );
+	}
+
+	/**
+	 * Get multiple, valid extension slugs from an incoming request.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return array<int, string>|null|WP_Error Slugs, null for wildcard, or validation error.
+	 */
+	private function get_multiple_extension_slugs( WP_REST_Request $request ) {
+		$raw = $request->get_param( 'extension_slug' );
+		if ( null === $raw ) {
+			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+		}
+		$raw_string = trim( self::coerce_string( $raw ) );
+		if ( '' === $raw_string ) {
+			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+		}
+
+		if ( 'any' === $raw_string ) {
+			return null;
+		}
+
+		$slugs = array();
+		foreach ( explode( ',', $raw_string ) as $possible_slug ) {
+			$slug = trim( $possible_slug );
+			if ( '' === $slug || 'any' === $slug || ! $this->is_valid_extension_slug( $slug ) ) {
+				return $this->invalid_error( __( 'extension_slug must be "any" or a comma-separated list of extension slugs.', 'woocommerce-subscriptions-engine' ) );
+			}
+
+			$slugs[ $slug ] = $slug;
+		}
+
+		return array_values( $slugs );
+	}
+
+	/**
+	 * Get a single, valid extension slug from an incoming request.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @return string|WP_Error Slug or validation error.
+	 */
+	private function get_single_extension_slug( WP_REST_Request $request ) {
+		$raw = $request->get_param( 'extension_slug' );
+		if ( null === $raw ) {
+			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+		}
+		$raw_string = trim( self::coerce_string( $raw ) );
+		if ( '' === $raw_string ) {
+			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+		}
+
+		if ( 'any' === $raw_string || false !== strpos( $raw_string, ',' ) || ! $this->is_valid_extension_slug( $raw_string ) ) {
+			return $this->invalid_error( __( 'extension_slug must be a concrete extension slug.', 'woocommerce-subscriptions-engine' ) );
+		}
+
+		return $raw_string;
+	}
+
+	/**
+	 * Whether a value is a valid extension slug.
+	 *
+	 * @param string $slug Possible extension slug.
+	 */
+	private function is_valid_extension_slug( string $slug ): bool {
+		return '' !== $slug && sanitize_key( $slug ) === $slug;
+	}
+
+	/**
+	 * Read a string param.
+	 *
+	 * @param WP_REST_Request $request  Request.
+	 * @param string          $key      Param key.
+	 * @param string          $fallback Fallback.
+	 */
+	private function string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
+		return sanitize_text_field( self::coerce_string( $request->get_param( $key ), $fallback ) );
+	}
+
+	/**
+	 * Read a nullable string param.
+	 *
+	 * @param WP_REST_Request $request Request.
+	 * @param string          $key     Param key.
+	 */
+	private function nullable_string_param( WP_REST_Request $request, string $key ): ?string {
+		$value = self::coerce_nullable_string( $request->get_param( $key ) );
+		if ( null === $value || '' === $value ) {
+			return null;
+		}
+
+		return sanitize_text_field( $value );
+	}
+
+	/**
+	 * Normalize a REST object payload to a string-keyed array.
+	 *
+	 * @param array<array-key, mixed> $value   Request value.
+	 * @param string                  $message Error message.
+	 * @return array<string, mixed>
+	 * @throws InvalidArgumentException If the array is not object-shaped.
+	 */
+	private function associative_array( array $value, string $message ): array {
+		$data = array();
+		foreach ( $value as $key => $item ) {
+			if ( ! is_string( $key ) ) {
+				throw new InvalidArgumentException( esc_html( $message ) );
+			}
+			$data[ $key ] = $item;
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Not-found error.
+	 */
+	private function not_found_error(): WP_Error {
+		return new WP_Error(
+			'woocommerce_subscriptions_engine_plan_not_found',
+			__( 'Plan not found.', 'woocommerce-subscriptions-engine' ),
+			array( 'status' => 404 )
+		);
+	}
+
+	/**
+	 * Invalid request error.
+	 *
+	 * @param string $message Message.
+	 */
+	private function invalid_error( string $message ): WP_Error {
+		return new WP_Error(
+			'woocommerce_subscriptions_engine_invalid_plan',
+			$message,
+			array( 'status' => 400 )
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
index f8353d3b776..03b6098231a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -30,6 +30,14 @@ final class Plan {

 	const DEFAULT_CATEGORY = 'SUBSCRIPTION';

+	const DEFAULT_STATUS = 'active';
+
+	const STATUS_ACTIVE = 'active';
+
+	const STATUS_ARCHIVED = 'archived';
+
+	const ALLOWED_STATUSES = array( self::STATUS_ACTIVE, self::STATUS_ARCHIVED );
+
 	const ALLOWED_POLICY_TYPES = array( 'percentage', 'fixed_amount', 'price' );

 	/**
@@ -95,6 +103,20 @@ final class Plan {
 	 */
 	private $category;

+	/**
+	 * Merchant lifecycle status.
+	 *
+	 * @var string
+	 */
+	private $status;
+
+	/**
+	 * Manual display order.
+	 *
+	 * @var int
+	 */
+	private $sort_order;
+
 	/**
 	 * Owning extension slug, or null until owner semantics are assigned.
 	 *
@@ -114,6 +136,8 @@ final class Plan {
 	 * @param DeliveryPolicy|null $delivery_policy Optional delivery policy.
 	 * @param PricingPolicy|null  $pricing_policy  Optional pricing policy.
 	 * @param string              $category        Plan category.
+	 * @param string              $status          Merchant lifecycle status.
+	 * @param int                 $sort_order      Manual display order.
 	 * @param string|null         $extension_slug  Owning extension slug.
 	 */
 	private function __construct(
@@ -126,8 +150,12 @@ final class Plan {
 		?DeliveryPolicy $delivery_policy,
 		?PricingPolicy $pricing_policy,
 		string $category,
+		string $status,
+		int $sort_order,
 		?string $extension_slug
 	) {
+		self::validate_status( $status );
+
 		$this->id              = $id;
 		$this->group_id        = $group_id;
 		$this->name            = $name;
@@ -137,6 +165,8 @@ final class Plan {
 		$this->delivery_policy = $delivery_policy;
 		$this->pricing_policy  = $pricing_policy;
 		$this->category        = $category;
+		$this->status          = $status;
+		$this->sort_order      = $sort_order;
 		$this->extension_slug  = $extension_slug;
 	}

@@ -176,6 +206,8 @@ final class Plan {
 			$delivery_policy,
 			$pricing_policy,
 			self::coerce_string( $args['category'] ?? null, self::DEFAULT_CATEGORY ),
+			self::coerce_string( $args['status'] ?? null, self::DEFAULT_STATUS ),
+			self::coerce_int( $args['sort_order'] ?? null, 0 ),
 			self::coerce_nullable_string( $args['extension_slug'] ?? null )
 		);
 	}
@@ -209,6 +241,8 @@ final class Plan {
 			isset( $row['delivery_policy'] ) && is_array( $row['delivery_policy'] ) ? DeliveryPolicy::from_array( $row['delivery_policy'] ) : null,
 			$pricing_policy,
 			self::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
+			self::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
+			self::coerce_int( $row['sort_order'] ?? null, 0 ),
 			self::coerce_nullable_string( $row['extension_slug'] ?? null )
 		);
 	}
@@ -354,6 +388,40 @@ final class Plan {
 		$this->category = $category;
 	}

+	/**
+	 * Merchant lifecycle status.
+	 */
+	public function get_status(): string {
+		return $this->status;
+	}
+
+	/**
+	 * Set the merchant lifecycle status.
+	 *
+	 * @param string $status Plan status.
+	 * @throws InvalidArgumentException If the status is unknown.
+	 */
+	public function set_status( string $status ): void {
+		self::validate_status( $status );
+		$this->status = $status;
+	}
+
+	/**
+	 * Manual display order.
+	 */
+	public function get_sort_order(): int {
+		return $this->sort_order;
+	}
+
+	/**
+	 * Set the manual display order.
+	 *
+	 * @param int $sort_order Sort order.
+	 */
+	public function set_sort_order( int $sort_order ): void {
+		$this->sort_order = $sort_order;
+	}
+
 	/**
 	 * Owning extension slug, or null.
 	 */
@@ -377,6 +445,24 @@ final class Plan {
 		return $this->pricing_policy->calculate_price( $base_price, $cycle );
 	}

+	/**
+	 * Calculate the line total for this plan and cycle.
+	 *
+	 * When no pricing policy is set, this returns unit_price * quantity. Otherwise
+	 * the plan delegates to its pricing policy.
+	 *
+	 * @param float $unit_price The product's base unit price for this cycle.
+	 * @param float $quantity   Quantity on the line.
+	 * @param int   $cycle      1-indexed cycle number.
+	 */
+	public function calculate_line_total( float $unit_price, float $quantity, int $cycle = 1 ): float {
+		if ( null === $this->pricing_policy ) {
+			return max( 0.0, $unit_price * $quantity );
+		}
+
+		return $this->pricing_policy->calculate_line_total( $unit_price, $quantity, $cycle );
+	}
+
 	/**
 	 * Serialize to the storage column shape (excluding generated id/timestamps).
 	 *
@@ -394,16 +480,33 @@ final class Plan {
 			'delivery_policy' => null !== $this->delivery_policy ? $this->delivery_policy->to_array() : null,
 			'pricing_policy'  => null !== $this->pricing_policy ? $this->pricing_policy->to_array() : null,
 			'category'        => $this->category,
+			'status'          => $this->status,
+			'sort_order'      => $this->sort_order,
 			'extension_slug'  => $this->extension_slug,
 		);
 	}

+	/**
+	 * Validate a plan lifecycle status.
+	 *
+	 * @param string $status Status to validate.
+	 * @throws InvalidArgumentException If the status is unknown.
+	 */
+	private static function validate_status( string $status ): void {
+		if ( ! in_array( $status, self::ALLOWED_STATUSES, true ) ) {
+			throw new InvalidArgumentException(
+				sprintf( 'Plan: invalid status "%s".', $status )
+			);
+		}
+	}
+
 	/**
 	 * Validate every entry in a pricing policy's policies[] and one_time_fees[].
 	 *
 	 * Rules:
 	 *  - policies[].type is one of percentage, fixed_amount, price.
 	 *  - policies[].value is numeric and non-negative; percentage is capped at 100.
+	 *  - policies[].duration_cycles is optional, integer, and positive.
 	 *  - one_time_fees[].amount is numeric and non-negative.
 	 *  - one_time_fees[].taxable is a bool.
 	 *  - one_time_fees[].tax_class is string or null (preserves '' != null).
@@ -424,6 +527,7 @@ final class Plan {
 			$type           = $entry['type'] ?? null;
 			$value          = $entry['value'] ?? null;
 			$starting_cycle = $entry['starting_cycle'] ?? null;
+			$duration       = $entry['duration_cycles'] ?? null;

 			if ( ! is_string( $type ) || ! in_array( $type, self::ALLOWED_POLICY_TYPES, true ) ) {
 				$shown = is_scalar( $type ) ? (string) $type : gettype( $type );
@@ -465,6 +569,20 @@ final class Plan {
 					);
 				}
 			}
+
+			if ( null !== $duration ) {
+				if ( ! is_int( $duration ) ) {
+					throw new InvalidArgumentException(
+						sprintf( 'pricing_policy.policies[%d]: duration_cycles must be an integer, got %s', (int) $index, gettype( $duration ) )
+					);
+				}
+
+				if ( $duration < 1 ) {
+					throw new InvalidArgumentException(
+						sprintf( 'pricing_policy.policies[%d]: duration_cycles must be at least 1, got %d', (int) $index, $duration )
+					);
+				}
+			}
 		}

 		foreach ( $pricing_policy->get_one_time_fees() as $index => $entry ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
index eb12a4cf6b6..3399c4e9b53 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
@@ -6,7 +6,7 @@
  * Mirrors the `pricing_policy` JSON column shape. Shape:
  *   {
  *     policies: [
- *       { type: 'percentage'|'fixed_amount'|'price', value: float, starting_cycle?: int },
+ *       { type: 'percentage'|'fixed_amount'|'price', value: float, starting_cycle?: int, duration_cycles?: int },
  *       ...
  *     ],
  *     one_time_fees: [
@@ -27,6 +27,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;

+use InvalidArgumentException;
+
 defined( 'ABSPATH' ) || exit;

 /**
@@ -41,7 +43,7 @@ final class PricingPolicy {
 	/**
 	 * Recurring price adjustments, applied in array order.
 	 *
-	 * @var array<int, array{type: string, value: float, starting_cycle?: int}>
+	 * @var array<int, array{type: string, value: float, starting_cycle?: int, duration_cycles?: int}>
 	 */
 	private $policies;

@@ -55,8 +57,8 @@ final class PricingPolicy {
 	/**
 	 * Build a pricing policy.
 	 *
-	 * @param array<int, array{type: string, value: float, starting_cycle?: int}>                   $policies      Recurring price adjustments.
-	 * @param array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}> $one_time_fees One-time fees.
+	 * @param array<int, array{type: string, value: float, starting_cycle?: int, duration_cycles?: int}> $policies      Recurring price adjustments.
+	 * @param array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}>      $one_time_fees One-time fees.
 	 */
 	public function __construct( array $policies, array $one_time_fees ) {
 		$this->policies      = $policies;
@@ -92,8 +94,11 @@ final class PricingPolicy {
 				'type'  => isset( $entry['type'] ) && is_scalar( $entry['type'] ) ? (string) $entry['type'] : '',
 				'value' => isset( $entry['value'] ) && is_numeric( $entry['value'] ) ? (float) $entry['value'] : 0.0,
 			);
-			if ( isset( $entry['starting_cycle'] ) && is_numeric( $entry['starting_cycle'] ) ) {
-				$policy['starting_cycle'] = (int) $entry['starting_cycle'];
+			if ( isset( $entry['starting_cycle'] ) ) {
+				$policy['starting_cycle'] = self::normalize_cycle( $entry['starting_cycle'], 'starting_cycle' );
+			}
+			if ( isset( $entry['duration_cycles'] ) ) {
+				$policy['duration_cycles'] = self::normalize_cycle( $entry['duration_cycles'], 'duration_cycles' );
 			}
 			$policies[] = $policy;
 		}
@@ -125,7 +130,7 @@ final class PricingPolicy {
 	/**
 	 * Recurring price adjustments. Each entry: `{type, value, starting_cycle?}`.
 	 *
-	 * @return array<int, array{type: string, value: float, starting_cycle?: int}>
+	 * @return array<int, array{type: string, value: float, starting_cycle?: int, duration_cycles?: int}>
 	 */
 	public function get_policies(): array {
 		return $this->policies;
@@ -150,6 +155,7 @@ final class PricingPolicy {
 	 *  - `type: 'price'`        -> `value` (replaces base price entirely).
 	 *  - `starting_cycle` gate: skip the entry when `$cycle < starting_cycle`.
 	 *    A missing `starting_cycle` means the entry applies to all cycles.
+	 *  - `duration_cycles` gate: skip the entry once the duration window ends.
 	 *  - Entries are applied in array order; later entries operate on the result.
 	 *
 	 * One-time fees are intentionally not applied here.
@@ -161,7 +167,7 @@ final class PricingPolicy {
 		$price = $base_price;

 		foreach ( $this->policies as $policy ) {
-			if ( isset( $policy['starting_cycle'] ) && $cycle < (int) $policy['starting_cycle'] ) {
+			if ( ! $this->policy_applies_to_cycle( $policy, $cycle ) ) {
 				continue;
 			}

@@ -186,6 +192,20 @@ final class PricingPolicy {
 		return $price;
 	}

+	/**
+	 * Apply the recurring policy chain to a line total for the given cycle.
+	 *
+	 * Line totals use the effective unit price produced by calculate_price().
+	 *
+	 * @param float $unit_price The product's base unit price for this cycle.
+	 * @param float $quantity   Quantity on the line.
+	 * @param int   $cycle      1-indexed cycle number.
+	 */
+	public function calculate_line_total( float $unit_price, float $quantity, int $cycle = 1 ): float {
+		$effective_unit_price = $this->calculate_price( $unit_price, $cycle );
+		return max( 0.0, $effective_unit_price * $quantity );
+	}
+
 	/**
 	 * Serialize back to the JSON column shape. Lossless round-trip with from_array().
 	 *
@@ -197,4 +217,57 @@ final class PricingPolicy {
 			'one_time_fees' => $this->one_time_fees,
 		);
 	}
+
+	/**
+	 * Whether a pricing policy entry applies to the requested cycle.
+	 *
+	 * @param array{starting_cycle?: int, duration_cycles?: int} $policy Policy entry.
+	 * @param int                                                $cycle  1-indexed cycle number.
+	 */
+	private function policy_applies_to_cycle( array $policy, int $cycle ): bool {
+		$starting_cycle = $policy['starting_cycle'] ?? 1;
+		if ( $cycle < $starting_cycle ) {
+			return false;
+		}
+
+		if ( isset( $policy['duration_cycles'] ) ) {
+			$last_cycle = $starting_cycle + $policy['duration_cycles'] - 1;
+			if ( $cycle > $last_cycle ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Normalize a cycle gate value, which must be an integer value.
+	 * Throws exceptions for invalid values.
+	 *
+	 * @param mixed  $value Raw field value.
+	 * @param string $field Field name.
+	 * @throws InvalidArgumentException If the value is not a whole number.
+	 */
+	private static function normalize_cycle( $value, string $field ): int {
+		$int_value = null;
+
+		if ( is_int( $value ) ) {
+			$int_value = $value;
+		} elseif ( is_float( $value ) && floor( $value ) === $value ) {
+			$int_value = (int) $value;
+		} elseif ( is_string( $value ) ) {
+			$validated = filter_var( $value, FILTER_VALIDATE_INT );
+			if ( false !== $validated ) {
+				$int_value = $validated;
+			}
+		}
+
+		if ( null !== $int_value && $int_value >= 0 ) {
+			return $int_value;
+		}
+
+		throw new InvalidArgumentException(
+			sprintf( 'pricing_policy.policies[].%s must be a positive integer.', $field )
+		);
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 74775ccaca4..68a64613c91 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -16,6 +16,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;

 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 defined( 'ABSPATH' ) || exit;
@@ -49,6 +50,7 @@ final class Bootstrap {
 		// back into the engine. Must run on every boot (not just activation) so
 		// AS can fire scheduled renewals.
 		RenewalEngine::register_hooks();
+		PlansController::register_hooks();

 		if ( did_action( 'init' ) ) {
 			self::maybe_install_schema();
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanGroupRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanGroupRepository.php
index c93c2126b0c..7d3b0c8a6f7 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanGroupRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanGroupRepository.php
@@ -79,6 +79,37 @@ final class PlanGroupRepository {
 		return PlanGroup::from_storage( $row );
 	}

+	/**
+	 * Persist changes to an existing plan group.
+	 *
+	 * @param PlanGroup $group Group to update. Must have an id.
+	 * @return bool True on success.
+	 * @throws \RuntimeException If the group has no id.
+	 */
+	public function update( PlanGroup $group ): bool {
+		global $wpdb;
+
+		$id = $group->get_id();
+		if ( null === $id ) {
+			throw new \RuntimeException( 'Cannot update a plan group that has no id.' );
+		}
+
+		$data = $group->to_storage();
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$updated = $wpdb->update(
+			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_GROUPS ),
+			array(
+				'name'             => $data['name'],
+				'options_display'  => wp_json_encode( $data['options_display'] ),
+				'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ),
+			),
+			array( 'id' => $id )
+		);
+
+		return false !== $updated;
+	}
+
 	/**
 	 * Delete a plan group by id.
 	 *
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 c85b1553c1e..ac0fd17b28f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -10,6 +10,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;

 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;

 defined( 'ABSPATH' ) || exit;

@@ -18,6 +19,8 @@ defined( 'ABSPATH' ) || exit;
  */
 final class PlanRepository {

+	use ScalarCoercion;
+
 	/**
 	 * Policy columns stored as JSON.
 	 *
@@ -25,6 +28,20 @@ final class PlanRepository {
 	 */
 	private const JSON_COLUMNS = array( 'options', 'billing_policy', 'delivery_policy', 'pricing_policy' );

+	/**
+	 * Columns callers may sort by through query().
+	 *
+	 * @var array<string, string>
+	 */
+	private const ORDERBY_COLUMNS = array(
+		'id'               => 'id',
+		'name'             => 'name',
+		'sort_order'       => 'sort_order',
+		'status'           => 'status',
+		'date_created_gmt' => 'date_created_gmt',
+		'date_updated_gmt' => 'date_updated_gmt',
+	);
+
 	/**
 	 * Insert a new plan and stamp its id back onto the entity.
 	 *
@@ -51,6 +68,8 @@ final class PlanRepository {
 				'inventory_policy' => null,
 				'pricing_policy'   => null !== $data['pricing_policy'] ? wp_json_encode( $data['pricing_policy'] ) : null,
 				'category'         => $data['category'],
+				'status'           => $data['status'],
+				'sort_order'       => $data['sort_order'],
 				'extension_slug'   => $data['extension_slug'],
 				'date_created_gmt' => $now,
 				'date_updated_gmt' => $now,
@@ -68,28 +87,92 @@ final class PlanRepository {
 	}

 	/**
-	 * Fetch a plan by id.
+	 * Fetch a plan by id and (optionally) extension slug.
+	 * Most usages from applications should specify the extension slug
+	 * to guard against cross-application collisions.
 	 *
-	 * @param int $id Plan id.
+	 * @param int         $id             Plan id.
+	 * @param string|null $extension_slug Extension slug to filter plans by.
 	 * @return Plan|null Hydrated plan, or null if not found.
 	 */
-	public function find( int $id ): ?Plan {
+	public function find( int $id, ?string $extension_slug = null ): ?Plan {
 		global $wpdb;

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

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
-		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+		$extension_clause = '';
+		$params           = array( $id );
+		if ( null !== $extension_slug && 'any' !== $extension_slug ) {
+			$extension_clause = ' AND extension_slug = %s';
+			$params[]         = $extension_slug;
+		}
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$row = $wpdb->get_row(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d {$extension_clause}", $params ),
+			ARRAY_A
+		);

 		if ( null === $row ) {
 			return null;
 		}

-		foreach ( self::JSON_COLUMNS as $column ) {
-			$row[ $column ] = self::decode_json( $row[ $column ] ?? null );
+		return $this->hydrate_row( $row );
+	}
+
+	/**
+	 * Query plans.
+	 *
+	 * Supported args: limit, offset, search, status, extension_slug, orderby,
+	 * order. Results default to manual order, oldest id as a stable tiebreaker.
+	 *
+	 * @param array<string, mixed> $args Query args.
+	 * @return array<int, Plan>
+	 */
+	public function query( array $args = array() ): array {
+		global $wpdb;
+
+		$table  = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$where  = $this->build_where_clauses( $args );
+		$order  = $this->build_order_clause( $args );
+		$limit  = max( 1, self::coerce_int( $args['limit'] ?? null, 50 ) );
+		$offset = max( 0, self::coerce_int( $args['offset'] ?? null, 0 ) );
+
+		$sql = "SELECT * FROM {$table}{$where} {$order} LIMIT %d OFFSET %d";
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+		$rows = $wpdb->get_results( $wpdb->prepare( $sql, $limit, $offset ), ARRAY_A );
+		if ( ! is_array( $rows ) ) {
+			return array();
 		}

-		return Plan::from_storage( $row );
+		$plans = array();
+		foreach ( $rows as $row ) {
+			if ( ! is_array( $row ) ) {
+				continue;
+			}
+			$plans[] = $this->hydrate_row( self::string_keyed_array( $row ) );
+		}
+
+		return $plans;
+	}
+
+	/**
+	 * Count plans matching a query.
+	 *
+	 * Supported args are the filter args accepted by query().
+	 *
+	 * @param array<string, mixed> $args Query args.
+	 */
+	public function count( array $args = array() ): int {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$where = $this->build_where_clauses( $args );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}{$where}" );
 	}

 	/**
@@ -120,6 +203,8 @@ final class PlanRepository {
 				'delivery_policy'  => null !== $data['delivery_policy'] ? wp_json_encode( $data['delivery_policy'] ) : null,
 				'pricing_policy'   => null !== $data['pricing_policy'] ? wp_json_encode( $data['pricing_policy'] ) : null,
 				'category'         => $data['category'],
+				'status'           => $data['status'],
+				'sort_order'       => $data['sort_order'],
 				'extension_slug'   => $data['extension_slug'],
 				'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ),
 			),
@@ -130,23 +215,210 @@ final class PlanRepository {
 	}

 	/**
-	 * Delete a plan by id.
+	 * Delete a plan by id and (optionally) extension slug.
+	 * Most usages from applications should specify the extension slug
+	 * to guard against cross-application operations.
 	 *
-	 * @param int $id Plan id.
+	 * @param int         $id             Plan id.
+	 * @param string|null $extension_slug Extension slug for the plan.
 	 * @return bool True when a row was removed.
 	 */
-	public function delete( int $id ): bool {
+	public function delete( int $id, ?string $extension_slug = null ): bool {
 		global $wpdb;

-		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
-		$deleted = $wpdb->delete(
-			SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ),
-			array( 'id' => $id )
+		$where = array(
+			'id' => $id,
 		);
+		if ( null !== $extension_slug ) {
+			$where['extension_slug'] = $extension_slug;
+		}
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), $where );

 		return (bool) $deleted;
 	}

+	/**
+	 * Persist manual sort-order values for plans in one extension.
+	 *
+	 * @param string          $extension_slug   Extension slug for the plans to operate on.
+	 * @param array<int, int> $sort_order_by_id Map of plan id => sort order.
+	 * @return bool True when every update succeeds.
+	 */
+	public function reorder( string $extension_slug, array $sort_order_by_id ): bool {
+		global $wpdb;
+
+		if ( ! self::is_valid_extension_slug( $extension_slug ) ) {
+			return false;
+		}
+
+		if ( array() === $sort_order_by_id ) {
+			return true;
+		}
+
+		$ok  = true;
+		$now = gmdate( 'Y-m-d H:i:s' );
+
+		$plans_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+		$ids         = array_map( 'intval', array_keys( $sort_order_by_id ) );
+		foreach ( $ids as $id ) {
+			if ( $id <= 0 ) {
+				return false;
+			}
+		}
+
+		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+		$params       = array_merge( array( $extension_slug ), $ids );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
+		$matched_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$plans_table} WHERE extension_slug = %s AND id IN ({$placeholders})", $params ) );
+		$matched_ids = is_array( $matched_ids )
+			? array_unique(
+				array_map(
+					static function ( $matched_id ): int {
+						return self::coerce_int( $matched_id );
+					},
+					$matched_ids
+				)
+			)
+			: array();
+		if ( count( $matched_ids ) !== count( $ids ) ) {
+			return false;
+		}
+
+		foreach ( $sort_order_by_id as $id => $sort_order ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+			$updated = $wpdb->update(
+				$plans_table,
+				array(
+					'sort_order'       => (int) $sort_order,
+					'date_updated_gmt' => $now,
+				),
+				array(
+					'id'             => (int) $id,
+					'extension_slug' => $extension_slug,
+				)
+			);
+
+			$ok = $ok && false !== $updated;
+		}
+
+		return $ok;
+	}
+
+	/**
+	 * Build SQL WHERE clauses from supported query args.
+	 *
+	 * @param array<string, mixed> $args Query args.
+	 */
+	private function build_where_clauses( array $args ): string {
+		global $wpdb;
+
+		$clauses = array();
+
+		$status = self::coerce_string( $args['status'] ?? null );
+		if ( '' !== $status ) {
+			$clauses[] = $wpdb->prepare( 'status = %s', $status );
+		}
+
+		if ( array_key_exists( 'extension_slug', $args ) ) {
+			if ( self::is_valid_extension_slug( $args['extension_slug'] ) ) {
+				$clauses[] = $wpdb->prepare( 'extension_slug = %s', $args['extension_slug'] );
+			} else {
+				$clauses[] = '0 = 1';
+			}
+		}
+
+		if ( array_key_exists( 'extension_slugs', $args ) && null !== $args['extension_slugs'] ) {
+			$are_extension_slugs_valid = false;
+
+			if ( is_array( $args['extension_slugs'] ) ) {
+				if ( 1 === count( $args['extension_slugs'] ) && 'any' === reset( $args['extension_slugs'] ) ) {
+					$are_extension_slugs_valid = true;
+				} else {
+					$possible_slugs = array_values( $args['extension_slugs'] );
+					$valid_slugs    = array();
+					foreach ( $possible_slugs as $possible_slug ) {
+						if ( self::is_valid_extension_slug( $possible_slug ) && is_string( $possible_slug ) ) {
+							$valid_slugs[ $possible_slug ] = $possible_slug;
+						}
+					}
+
+					// Require all slugs to be valid before running the query.
+					if ( array() !== $valid_slugs && count( $valid_slugs ) === count( $possible_slugs ) ) {
+						$are_extension_slugs_valid = true;
+
+						$extension_slugs = array_values( $valid_slugs );
+						$clauses[]       = $wpdb->prepare( 'extension_slug IN (' . implode( ',', array_fill( 0, count( $extension_slugs ), '%s' ) ) . ')', $extension_slugs );
+					}
+				}
+			}
+
+			if ( ! $are_extension_slugs_valid ) {
+				$clauses[] = '0 = 1';
+			}
+		}
+
+		$search = self::coerce_string( $args['search'] ?? null );
+		if ( '' !== $search ) {
+			$like      = '%' . $wpdb->esc_like( $search ) . '%';
+			$clauses[] = $wpdb->prepare( '(name LIKE %s OR description LIKE %s)', $like, $like );
+		}
+
+		if ( empty( $clauses ) ) {
+			return '';
+		}
+
+		return ' WHERE ' . implode( ' AND ', $clauses );
+	}
+
+	/**
+	 * Build a safe ORDER BY clause from supported query args.
+	 *
+	 * @param array<string, mixed> $args Query args.
+	 */
+	private function build_order_clause( array $args ): string {
+		$orderby_arg = self::coerce_string( $args['orderby'] ?? null );
+		$orderby     = isset( self::ORDERBY_COLUMNS[ $orderby_arg ] )
+			? self::ORDERBY_COLUMNS[ $orderby_arg ]
+			: 'sort_order';
+		$order       = 'desc' === strtolower( self::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';
+
+		if ( 'sort_order' === $orderby ) {
+			return "ORDER BY sort_order {$order}, id ASC";
+		}
+
+		return "ORDER BY {$orderby} {$order}, id ASC";
+	}
+
+	/**
+	 * Whether a value is a valid concrete extension slug.
+	 *
+	 * @param mixed $slug Possible extension slug.
+	 */
+	private static function is_valid_extension_slug( $slug ): bool {
+		if ( ! is_string( $slug ) ) {
+			return false;
+		}
+		if ( '' === $slug || 'any' === $slug ) {
+			return false;
+		}
+		return true;
+	}
+
+	/**
+	 * Hydrate a database row into a plan.
+	 *
+	 * @param array<string, mixed> $row Raw row.
+	 */
+	private function hydrate_row( array $row ): Plan {
+		foreach ( self::JSON_COLUMNS as $column ) {
+			$row[ $column ] = self::decode_json( $row[ $column ] ?? null );
+		}
+
+		return Plan::from_storage( $row );
+	}
+
 	/**
 	 * Decode a JSON column into an array.
 	 *
@@ -170,4 +442,21 @@ final class PlanRepository {

 		return is_array( $decoded ) ? $decoded : array();
 	}
+
+	/**
+	 * Normalize a database row to string keys.
+	 *
+	 * @param array<array-key, mixed> $row Raw row.
+	 * @return array<string, mixed>
+	 */
+	private static function string_keyed_array( array $row ): array {
+		$data = array();
+		foreach ( $row as $key => $value ) {
+			if ( is_string( $key ) ) {
+				$data[ $key ] = $value;
+			}
+		}
+
+		return $data;
+	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
index 7b288ba0f89..f9cbb84efa0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -33,13 +33,14 @@ final class SchemaInstaller {
 	 *         references, totals, stamps); immutable cycle records keyed on
 	 *         `(contract_id, kind)`; per-contract snapshots deduped by copy-forward.
 	 * 2.1.0 - rename `app_id` to `extension_slug` in plan_groups table.
+	 * 2.1.1 - add `status` and `sort_order` columns to plans table.
 	 *
 	 * Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
 	 * does not change an existing column's nullability or drop unused ones, so a dev box
 	 * on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
 	 * to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
 	 */
-	const VERSION = '2.1.0';
+	const VERSION = '2.1.1';

 	/**
 	 * Option key tracking the installed schema version.
@@ -202,12 +203,15 @@ final class SchemaInstaller {
   inventory_policy JSON NULL,
   pricing_policy JSON NULL,
   category VARCHAR(32) NOT NULL DEFAULT 'SUBSCRIPTION',
+  status VARCHAR(20) NOT NULL DEFAULT 'active',
+  sort_order INT NOT NULL DEFAULT 0,
   extension_slug VARCHAR(64) NULL,
   date_created_gmt DATETIME NOT NULL,
   date_updated_gmt DATETIME NOT NULL,
   PRIMARY KEY  (id),
   KEY group_id (group_id),
   KEY category (category),
+  KEY status_sort (status, sort_order, id),
   KEY extension_slug (extension_slug)
 ) {$collate};";

diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
new file mode 100644
index 00000000000..0c93c1100c0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Shared admin REST permission checks.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared admin REST permission checks.
+ */
+class RESTPermissions {
+
+	/**
+	 * Require a logged in user with the manage_woocommerce capability.
+	 *
+	 * @return true|\WP_Error
+	 */
+	public function require_admin_permission() {
+		if ( ! is_user_logged_in() ) {
+			return new \WP_Error(
+				'woocommerce_subscriptions_engine_not_authenticated',
+				__( 'You must be logged in to access this resource.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 401 )
+			);
+		}
+
+		// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers manage_woocommerce.
+		if ( ! current_user_can( 'manage_woocommerce' ) ) {
+			return new \WP_Error(
+				'woocommerce_subscriptions_engine_insufficient_permissions',
+				__( 'Sorry, you are not allowed to access this resource.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 403 )
+			);
+		}
+
+		return true;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
new file mode 100644
index 00000000000..f51b60f5bcb
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
@@ -0,0 +1,497 @@
+<?php
+/**
+ * Integration tests for the plans REST controller.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api\Rest;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use EngineIntegrationTestCase;
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController
+ */
+class PlansControllerTest extends EngineIntegrationTestCase {
+
+	private const BASE = '/wc/v3/subscriptions-engine/plans';
+
+	private const EXTENSION_SLUG = 'woocommerce-subscriptions-lite';
+
+	/**
+	 * Admin user id.
+	 *
+	 * @var int
+	 */
+	private $admin_id;
+
+	public function setUp(): void {
+		parent::setUp();
+
+		$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$this->assertIsInt( $admin_id );
+
+		$this->admin_id = $admin_id;
+		rest_get_server();
+	}
+
+	public function tearDown(): void {
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	public function test_collection_requires_manage_woocommerce(): void {
+		wp_set_current_user( 0 );
+
+		$response = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+
+		$this->assertSame( 401, $response->get_status() );
+	}
+
+	public function test_create_list_and_partial_patch_preserves_advanced_pricing_fields(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Monthly',
+				'description'    => 'Ships every month',
+				'billing_policy' => array(
+					'period'         => 'month',
+					'interval'       => 1,
+					'max_cycles'     => 12,
+					'trial_duration' => array(
+						'length' => 7,
+						'unit'   => 'day',
+					),
+				),
+				'pricing_policy' => array(
+					'policies'      => array(
+						array(
+							'type'  => 'percentage',
+							'value' => 10,
+						),
+					),
+					'one_time_fees' => array(
+						array(
+							'kind'      => 'setup',
+							'amount'    => 5,
+							'taxable'   => true,
+							'tax_class' => '',
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 201, $created->get_status() );
+		$created_data = $this->response_data( $created );
+		$this->assertSame( 'global', $created_data['scope'] );
+		$this->assertSame( Plan::STATUS_ACTIVE, $created_data['status'] );
+		$this->assertSame( self::EXTENSION_SLUG, $created_data['extension_slug'] );
+
+		$id = $this->int_value( $created_data, 'id' );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Monthly plus',
+				'billing_policy' => array(
+					'period'   => 'week',
+					'interval' => 2,
+				),
+				'pricing_policy' => array(
+					'policies' => array(
+						array(
+							'type'            => 'fixed_amount',
+							'value'           => 2,
+							'duration_cycles' => 3,
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 200, $patched->get_status() );
+		$patched_data   = $this->response_data( $patched );
+		$billing_policy = $this->array_value( $patched_data, 'billing_policy' );
+		$pricing_policy = $this->array_value( $patched_data, 'pricing_policy' );
+		$policies       = $this->array_value( $pricing_policy, 'policies' );
+		$first_policy   = $this->array_value( $policies, 0 );
+		$one_time_fees  = $this->array_value( $pricing_policy, 'one_time_fees' );
+		$first_fee      = $this->array_value( $one_time_fees, 0 );
+		$this->assertSame( 'Monthly plus', $patched_data['name'] );
+		$this->assertSame( 'week', $billing_policy['period'] );
+		$this->assertSame( 2, $billing_policy['interval'] );
+		$this->assertSame( 12, $billing_policy['max_cycles'] );
+		$this->assertSame(
+			array(
+				'length' => 7,
+				'unit'   => 'day',
+			),
+			$billing_policy['trial_duration']
+		);
+		$this->assertSame( 'fixed_amount', $first_policy['type'] );
+		$this->assertSame( 3, $first_policy['duration_cycles'] );
+		$this->assertSame( 'setup', $first_fee['kind'] );
+
+		$list = $this->request(
+			'GET',
+			self::BASE,
+			array(),
+			array(
+				'search'         => 'plus',
+				'extension_slug' => self::EXTENSION_SLUG,
+			)
+		);
+		$this->assertSame( 200, $list->get_status() );
+		$this->assertSame( '1', $list->get_headers()['X-WP-Total'] );
+		$this->assertCount( 1, $this->response_data( $list ) );
+	}
+
+	public function test_list_with_multiple_extension_slugs_returns_all_plans(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$first_id  = $this->create_plan( 'First', self::EXTENSION_SLUG );
+		$second_id = $this->create_plan( 'Second', 'woocommerce-subscriptions-test' );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => implode( ',', array( self::EXTENSION_SLUG, 'woocommerce-subscriptions-test' ) ) ) );
+		$this->assertSame( 200, $list->get_status() );
+		$this->assertSame( '2', $list->get_headers()['X-WP-Total'] );
+		$response_data = $this->response_data( $list );
+		$this->assertIsArray( $response_data );
+		$first_data  = $this->array_value( $response_data, 0 );
+		$second_data = $this->array_value( $response_data, 1 );
+		$this->assertCount( 2, $response_data );
+		$this->assertSame( $first_id, $this->int_value( $first_data, 'id' ) );
+		$this->assertSame( self::EXTENSION_SLUG, $first_data['extension_slug'] );
+		$this->assertSame( $second_id, $this->int_value( $second_data, 'id' ) );
+		$this->assertSame( 'woocommerce-subscriptions-test', $second_data['extension_slug'] );
+	}
+
+	public function test_list_trims_and_deduplicates_extension_slugs(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$first_id  = $this->create_plan( 'First', self::EXTENSION_SLUG );
+		$second_id = $this->create_plan( 'Second', 'woocommerce-subscriptions-test' );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG . ', ' . self::EXTENSION_SLUG . ',woocommerce-subscriptions-test' ) );
+
+		$this->assertSame( 200, $list->get_status() );
+		$this->assertSame( '2', $list->get_headers()['X-WP-Total'] );
+		$response_data = $this->response_data( $list );
+		$this->assertCount( 2, $response_data );
+		$this->assertSame(
+			array( $first_id, $second_id ),
+			array_map(
+				function ( $row ): int {
+					$this->assertIsArray( $row );
+					return $this->int_value( $row, 'id' );
+				},
+				$response_data
+			)
+		);
+	}
+
+	public function test_list_rejects_invalid_extension_slug_lists(): void {
+		wp_set_current_user( $this->admin_id );
+
+		foreach ( array( 'any,' . self::EXTENSION_SLUG, self::EXTENSION_SLUG . ',', ',' . self::EXTENSION_SLUG, 'lite,,test' ) as $extension_slug ) {
+			$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => $extension_slug ) );
+
+			$this->assertSame( 400, $list->get_status(), 'Failed for extension_slug=' . $extension_slug );
+		}
+	}
+
+	public function test_list_with_any_extension_slug_returns_all_plans(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$first_id  = $this->create_plan( 'First', self::EXTENSION_SLUG );
+		$second_id = $this->create_plan( 'Second', 'woocommerce-subscriptions-test' );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => 'any' ) );
+		$this->assertSame( 200, $list->get_status() );
+		$this->assertSame( '2', $list->get_headers()['X-WP-Total'] );
+		$response_data = $this->response_data( $list );
+		$this->assertIsArray( $response_data );
+		$first_data  = $this->array_value( $response_data, 0 );
+		$second_data = $this->array_value( $response_data, 1 );
+		$this->assertCount( 2, $response_data );
+		$this->assertSame( $first_id, $this->int_value( $first_data, 'id' ) );
+		$this->assertSame( self::EXTENSION_SLUG, $first_data['extension_slug'] );
+		$this->assertSame( $second_id, $this->int_value( $second_data, 'id' ) );
+		$this->assertSame( 'woocommerce-subscriptions-test', $second_data['extension_slug'] );
+	}
+
+	public function test_list_can_order_by_status(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$active_before = $this->create_plan( 'Active before' );
+		$archived      = $this->create_plan( 'Archived' );
+		$active_after  = $this->create_plan( 'Active after' );
+
+		$archived_response = $this->request(
+			'PATCH',
+			self::BASE . '/' . $archived,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => Plan::STATUS_ARCHIVED,
+			)
+		);
+		$this->assertSame( 200, $archived_response->get_status() );
+
+		$list = $this->request(
+			'GET',
+			self::BASE,
+			array(),
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'orderby'        => 'status',
+				'order'          => 'desc',
+			)
+		);
+
+		$this->assertSame( 200, $list->get_status() );
+		$this->assertSame( array( $archived, $active_before, $active_after ), $this->response_ids( $list ) );
+	}
+
+	public function test_single_plan_routes_reject_wildcard_and_list_extension_slugs(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$id = $this->create_plan( 'Scoped' );
+
+		foreach ( array( 'any', self::EXTENSION_SLUG . ',woocommerce-subscriptions-test' ) as $extension_slug ) {
+			$this->assertSame( 400, $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => $extension_slug ) )->get_status() );
+			$this->assertSame(
+				400,
+				$this->request(
+					'PATCH',
+					self::BASE . '/' . $id,
+					array(
+						'extension_slug' => $extension_slug,
+						'name'           => 'Invalid scope',
+					)
+				)->get_status()
+			);
+			$this->assertSame(
+				400,
+				$this->request(
+					'POST',
+					self::BASE . '/reorder',
+					array(
+						'extension_slug' => $extension_slug,
+						'ids'            => array( $id ),
+					)
+				)->get_status()
+			);
+		}
+	}
+
+	public function test_create_rejects_wildcard_and_list_extension_slugs(): void {
+		wp_set_current_user( $this->admin_id );
+
+		foreach ( array( 'any', self::EXTENSION_SLUG . ',woocommerce-subscriptions-test' ) as $extension_slug ) {
+			$response = $this->request(
+				'POST',
+				self::BASE,
+				array(
+					'name'           => 'Invalid scope',
+					'billing_policy' => array(
+						'period'   => 'month',
+						'interval' => 1,
+					),
+					'extension_slug' => $extension_slug,
+				)
+			);
+
+			$this->assertSame( 400, $response->get_status() );
+		}
+	}
+
+	public function test_archive_restore_and_reorder(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$first  = $this->create_plan( 'First' );
+		$second = $this->create_plan( 'Second' );
+
+		$archived = $this->request(
+			'PATCH',
+			self::BASE . '/' . $first,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => Plan::STATUS_ARCHIVED,
+			)
+		);
+		$this->assertSame( Plan::STATUS_ARCHIVED, $this->response_data( $archived )['status'] );
+
+		$restored = $this->request(
+			'PATCH',
+			self::BASE . '/' . $first,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => Plan::STATUS_ACTIVE,
+			)
+		);
+		$this->assertSame( Plan::STATUS_ACTIVE, $this->response_data( $restored )['status'] );
+
+		$reordered = $this->request(
+			'POST',
+			self::BASE . '/reorder',
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'ids'            => array( $second, $first ),
+			)
+		);
+		$this->assertSame( 200, $reordered->get_status() );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$ids  = array();
+		foreach ( $this->response_data( $list ) as $row ) {
+			$this->assertIsArray( $row );
+			$ids[] = $this->int_value( $row, 'id' );
+		}
+		$this->assertSame( array( $second, $first ), $ids );
+	}
+
+	public function test_reorder_rejects_duplicate_ids(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$first  = $this->create_plan( 'First' );
+		$second = $this->create_plan( 'Second' );
+
+		$reordered = $this->request(
+			'POST',
+			self::BASE . '/reorder',
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'ids'            => array( $second, $first, $second ),
+			)
+		);
+
+		$this->assertSame( 400, $reordered->get_status() );
+	}
+
+	public function test_delete_route_is_not_exposed(): void {
+		wp_set_current_user( $this->admin_id );
+		$id = $this->create_plan( 'Delete guard' );
+
+		$response = rest_do_request( new WP_REST_Request( 'DELETE', self::BASE . '/' . $id ) );
+
+		$this->assertContains( $response->get_status(), array( 404, 405 ), 'DELETE must not remove plans.' );
+	}
+
+	/**
+	 * Create a basic plan and return its id.
+	 *
+	 * @param string $name           Plan name.
+	 * @param string $extension_slug Extension slug.
+	 * @return int Plan id.
+	 */
+	private function create_plan( string $name, string $extension_slug = self::EXTENSION_SLUG ): int {
+		$response = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'name'           => $name,
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'extension_slug' => $extension_slug,
+			)
+		);
+
+		$this->assertSame( 201, $response->get_status() );
+
+		return $this->int_value( $this->response_data( $response ), 'id' );
+	}
+
+	/**
+	 * Make a REST request with JSON-like params.
+	 *
+	 * @param string               $method Method.
+	 * @param string               $path   Route path.
+	 * @param array<string, mixed> $body   Body params.
+	 * @param array<string, mixed> $query  Query params.
+	 */
+	private function request( string $method, string $path, array $body = array(), array $query = array() ): WP_REST_Response {
+		$request = new WP_REST_Request( $method, $path );
+		if ( ! empty( $body ) ) {
+			$request->set_body_params( $body );
+		}
+		if ( ! empty( $query ) ) {
+			$request->set_query_params( $query );
+		}
+
+		return rest_do_request( $request );
+	}
+
+	/**
+	 * Get response data as an array.
+	 *
+	 * @param WP_REST_Response $response Response.
+	 * @return array<array-key, mixed>
+	 */
+	private function response_data( WP_REST_Response $response ): array {
+		$data = $response->get_data();
+		$this->assertIsArray( $data );
+
+		return $data;
+	}
+
+	/**
+	 * Get response item ids.
+	 *
+	 * @param WP_REST_Response $response Response.
+	 * @return array<int, int>
+	 */
+	private function response_ids( WP_REST_Response $response ): array {
+		$ids = array();
+		foreach ( $this->response_data( $response ) as $row ) {
+			$this->assertIsArray( $row );
+			$ids[] = $this->int_value( $row, 'id' );
+		}
+
+		return $ids;
+	}
+
+	/**
+	 * Get a nested array value.
+	 *
+	 * @param array<array-key, mixed> $data Data.
+	 * @param array-key               $key  Key.
+	 * @return array<array-key, mixed>
+	 */
+	private function array_value( array $data, $key ): array {
+		$this->assertArrayHasKey( $key, $data );
+		$value = $data[ $key ];
+		$this->assertIsArray( $value );
+
+		return $value;
+	}
+
+	/**
+	 * Get an integer value.
+	 *
+	 * @param array<array-key, mixed> $data Data.
+	 * @param array-key               $key  Key.
+	 */
+	private function int_value( array $data, $key ): int {
+		$this->assertArrayHasKey( $key, $data );
+		$value = $data[ $key ];
+		if ( ! is_numeric( $value ) ) {
+			$this->fail( 'Expected a numeric value.' );
+		}
+
+		return (int) $value;
+	}
+}
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 17f93ac5de5..80e0e6ab42c 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
@@ -34,6 +34,25 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		return ( new PlanGroupRepository() )->insert( $group );
 	}

+	private function make_plan( PlanRepository $repo, int $group_id, string $name, string $extension_slug, int $sort_order = 0 ): int {
+		return $repo->insert(
+			Plan::create(
+				$group_id,
+				array(
+					'name'           => $name,
+					'billing_policy' => BillingPolicy::from_array(
+						array(
+							'period'   => 'month',
+							'interval' => 1,
+						)
+					),
+					'extension_slug' => $extension_slug,
+					'sort_order'     => $sort_order,
+				)
+			)
+		);
+	}
+
 	public function test_plan_group_round_trips(): void {
 		$repo = new PlanGroupRepository();

@@ -90,6 +109,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 						),
 					)
 				),
+				'status'         => Plan::STATUS_ARCHIVED,
+				'sort_order'     => 4,
 				'extension_slug' => 'lite',
 			)
 		);
@@ -105,6 +126,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'A monthly plan', $fetched->get_description() );
 		$this->assertSame( $group_id, $fetched->get_group_id() );
 		$this->assertSame( 'lite', $fetched->get_extension_slug() );
+		$this->assertSame( Plan::STATUS_ARCHIVED, $fetched->get_status() );
+		$this->assertSame( 4, $fetched->get_sort_order() );
 		$this->assertSame( 'month', $fetched->get_billing_policy()->get_period() );
 		$this->assertSame( 12, $fetched->get_billing_policy()->get_max_cycles() );
 		$this->assertNotNull( $fetched->get_pricing_policy() );
@@ -157,11 +180,163 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		$id   = $repo->insert( $plan );

 		$plan->set_name( 'After' );
+		$plan->set_status( Plan::STATUS_ARCHIVED );
+		$plan->set_sort_order( 8 );
 		$this->assertTrue( $repo->update( $plan ) );

 		$updated = $repo->find( $id );
 		$this->assertInstanceOf( Plan::class, $updated );
 		$this->assertSame( 'After', $updated->get_name() );
+		$this->assertSame( Plan::STATUS_ARCHIVED, $updated->get_status() );
+		$this->assertSame( 8, $updated->get_sort_order() );
+	}
+
+	public function test_query_count_and_reorder_use_plan_lifecycle_fields(): void {
+		$group_id = $this->make_group();
+		$repo     = new PlanRepository();
+
+		$first    = Plan::create(
+			$group_id,
+			array(
+				'name'           => 'Alpha monthly',
+				'billing_policy' => BillingPolicy::from_array(
+					array(
+						'period'   => 'month',
+						'interval' => 1,
+					)
+				),
+				'status'         => Plan::STATUS_ACTIVE,
+				'sort_order'     => 1,
+				'extension_slug' => 'lite',
+			)
+		);
+		$second   = Plan::create(
+			$group_id,
+			array(
+				'name'           => 'Beta weekly',
+				'billing_policy' => BillingPolicy::from_array(
+					array(
+						'period'   => 'week',
+						'interval' => 1,
+					)
+				),
+				'status'         => Plan::STATUS_ACTIVE,
+				'sort_order'     => 2,
+				'extension_slug' => 'lite',
+			)
+		);
+		$archived = Plan::create(
+			$group_id,
+			array(
+				'name'           => 'Archived yearly',
+				'billing_policy' => BillingPolicy::from_array(
+					array(
+						'period'   => 'year',
+						'interval' => 1,
+					)
+				),
+				'status'         => Plan::STATUS_ARCHIVED,
+				'sort_order'     => 3,
+				'extension_slug' => 'lite',
+			)
+		);
+
+		$first_id    = $repo->insert( $first );
+		$second_id   = $repo->insert( $second );
+		$archived_id = $repo->insert( $archived );
+
+		$active = $repo->query(
+			array(
+				'status' => Plan::STATUS_ACTIVE,
+				'search' => 'weekly',
+			)
+		);
+
+		$this->assertCount( 1, $active );
+		$this->assertSame( $second_id, $active[0]->get_id() );
+		$this->assertSame( 1, $repo->count( array( 'status' => Plan::STATUS_ARCHIVED ) ) );
+
+		$this->assertTrue(
+			$repo->reorder(
+				'lite',
+				array(
+					$first_id    => 9,
+					$second_id   => 1,
+					$archived_id => 2,
+				)
+			)
+		);
+
+		$ordered = $repo->query(
+			array(
+				'orderby' => 'sort_order',
+				'order'   => 'asc',
+				'limit'   => 3,
+			)
+		);
+
+		$this->assertSame( array( $second_id, $archived_id, $first_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $ordered ) );
+	}
+
+	public function test_invalid_extension_scopes_do_not_return_unscoped_results(): void {
+		$group_id = $this->make_group();
+		$repo     = new PlanRepository();
+
+		$id = $this->make_plan( $repo, $group_id, 'Scoped', 'lite' );
+
+		$this->assertInstanceOf( Plan::class, $repo->find( $id, 'any' ) );
+		// Test with extension_slugs array.
+		$this->assertCount( 1, $repo->query( array( 'extension_slugs' => array( 'any' ) ) ) );
+		$this->assertSame( 1, $repo->count( array( 'extension_slugs' => array( 'any' ) ) ) );
+		// Test with null extension_slugs.
+		$this->assertCount( 1, $repo->query( array( 'extension_slugs' => null ) ) );
+		$this->assertSame( 1, $repo->count( array( 'extension_slugs' => null ) ) );
+
+		$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( 'lite', '' ) ) ) );
+		$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array( 'bad 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();
+
+		$first_id = $this->make_plan( $repo, $group_id, 'First', 'lite', 1 );
+		$other_id = $this->make_plan( $repo, $group_id, 'Other', 'other-extension', 2 );
+
+		$this->assertFalse(
+			$repo->reorder(
+				'lite',
+				array(
+					$first_id => 9,
+					999999    => 1,
+				)
+			)
+		);
+
+		$first = $repo->find( $first_id, 'lite' );
+		$this->assertInstanceOf( Plan::class, $first );
+		$this->assertSame( 1, $first->get_sort_order() );
+
+		$this->assertFalse(
+			$repo->reorder(
+				'lite',
+				array(
+					$first_id => 9,
+					$other_id => 1,
+				)
+			)
+		);
+
+		$first = $repo->find( $first_id, 'lite' );
+		$other = $repo->find( $other_id, 'other-extension' );
+		$this->assertInstanceOf( Plan::class, $first );
+		$this->assertInstanceOf( Plan::class, $other );
+		$this->assertSame( 1, $first->get_sort_order() );
+		$this->assertSame( 2, $other->get_sort_order() );
 	}

 	public function test_delete_removes_the_row(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
index d96649bef37..7b2e110b635 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
@@ -104,6 +104,20 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
 		$this->assertSame( 'extension_slug', $column );
 	}

+	public function test_plans_table_has_status_and_sort_order_columns(): void {
+		global $wpdb;
+
+		$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$status = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'status' ) );
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$sort_order = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'sort_order' ) );
+
+		$this->assertSame( 'status', $status );
+		$this->assertSame( 'sort_order', $sort_order );
+	}
+
 	public function test_contracts_table_has_extension_slug_column(): void {
 		global $wpdb;

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
index 5db48cfc449..c990347ea3a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
@@ -41,6 +41,8 @@ class PlanTest extends TestCase {
 		$this->assertNull( $plan->get_id() );
 		$this->assertSame( 5, $plan->get_group_id() );
 		$this->assertSame( Plan::DEFAULT_CATEGORY, $plan->get_category() );
+		$this->assertSame( Plan::STATUS_ACTIVE, $plan->get_status() );
+		$this->assertSame( 0, $plan->get_sort_order() );
 		$this->assertNull( $plan->get_extension_slug() );
 	}

@@ -78,6 +80,36 @@ class PlanTest extends TestCase {
 		$this->assertSame( 42.0, $plan->calculate_price( 42.0 ) );
 	}

+	public function test_status_and_sort_order_are_mutable(): void {
+		$plan = Plan::create(
+			1,
+			array(
+				'name'           => 'Ordered',
+				'billing_policy' => $this->billing(),
+				'sort_order'     => 3,
+			)
+		);
+
+		$plan->set_status( Plan::STATUS_ARCHIVED );
+		$plan->set_sort_order( 7 );
+
+		$this->assertSame( Plan::STATUS_ARCHIVED, $plan->get_status() );
+		$this->assertSame( 7, $plan->get_sort_order() );
+	}
+
+	public function test_invalid_status_is_rejected(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		Plan::create(
+			1,
+			array(
+				'name'           => 'Bad status',
+				'billing_policy' => $this->billing(),
+				'status'         => 'deleted',
+			)
+		);
+	}
+
 	public function test_invalid_pricing_policy_type_is_rejected(): void {
 		$this->expectException( InvalidArgumentException::class );

@@ -128,6 +160,8 @@ class PlanTest extends TestCase {
 			array(
 				'name'           => 'Owned',
 				'billing_policy' => $this->billing(),
+				'status'         => Plan::STATUS_ARCHIVED,
+				'sort_order'     => 9,
 				'extension_slug' => 'lite',
 			)
 		);
@@ -135,6 +169,8 @@ class PlanTest extends TestCase {
 		$storage = $plan->to_storage();

 		$this->assertSame( 'lite', $storage['extension_slug'] );
+		$this->assertSame( Plan::STATUS_ARCHIVED, $storage['status'] );
+		$this->assertSame( 9, $storage['sort_order'] );
 		$this->assertSame( 3, $storage['group_id'] );
 		$this->assertIsArray( $storage['billing_policy'] );
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
index f976a8cfc86..5523974ac71 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
@@ -9,6 +9,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;

+use InvalidArgumentException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;

@@ -40,6 +41,21 @@ class PricingPolicyTest extends TestCase {
 		$this->assertSame( 90.0, $policy->calculate_price( 100.0 ) );
 	}

+	public function test_line_total_uses_effective_unit_price_for_quantity(): void {
+		$policy = PricingPolicy::from_array(
+			array(
+				'policies' => array(
+					array(
+						'type'  => 'percentage',
+						'value' => 10,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 270.0, $policy->calculate_line_total( 100.0, 3.0 ) );
+	}
+
 	public function test_fixed_amount_is_clamped_at_zero(): void {
 		$policy = PricingPolicy::from_array(
 			array(
@@ -74,6 +90,62 @@ class PricingPolicyTest extends TestCase {
 		$this->assertSame( 5.0, $policy->calculate_price( 50.0, 2 ) );
 	}

+	public function test_duration_cycles_limits_policy_window(): void {
+		$policy = PricingPolicy::from_array(
+			array(
+				'policies' => array(
+					array(
+						'type'            => 'percentage',
+						'value'           => 50,
+						'starting_cycle'  => 2,
+						'duration_cycles' => 2,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 100.0, $policy->calculate_price( 100.0, 1 ) );
+		$this->assertSame( 50.0, $policy->calculate_price( 100.0, 2 ) );
+		$this->assertSame( 50.0, $policy->calculate_price( 100.0, 3 ) );
+		$this->assertSame( 100.0, $policy->calculate_price( 100.0, 4 ) );
+	}
+
+	/**
+	 * @dataProvider provide_invalid_cycle_gate_values
+	 *
+	 * @param string $field Cycle gate field.
+	 * @param mixed  $value Invalid value.
+	 */
+	public function test_invalid_cycle_gate_values_are_rejected_by_pricing_policy( string $field, $value ): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		PricingPolicy::from_array(
+			array(
+				'policies' => array(
+					array(
+						'type'  => 'percentage',
+						'value' => 10,
+						$field  => $value,
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: mixed}>
+	 */
+	public function provide_invalid_cycle_gate_values(): array {
+		return array(
+			'fractional starting_cycle float'    => array( 'starting_cycle', 1.5 ),
+			'fractional starting_cycle string'   => array( 'starting_cycle', '1.5' ),
+			'non-numeric starting_cycle string'  => array( 'starting_cycle', 'soon' ),
+			'fractional duration_cycles float'   => array( 'duration_cycles', 1.5 ),
+			'fractional duration_cycles string'  => array( 'duration_cycles', '1.5' ),
+			'non-numeric duration_cycles string' => array( 'duration_cycles', 'forever' ),
+		);
+	}
+
 	public function test_whole_number_values_normalize_to_float(): void {
 		$policy = PricingPolicy::from_array(
 			array(