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(