Commit 1ebdbda1bb9 for woocommerce
commit 1ebdbda1bb91de6b79f683257386a47818812814
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Thu Oct 8 12:36:39 2026 +0200
Subscriptions engine: plans as records with plan views and a plan write facade (#69519)
Subscriptions engine: store plans as records with one plan facade
- Store plan billing, pricing and delivery policies as opaque payloads and drop description, category, sort order and merchant code
- Add plan meta, registered plan statuses and schema 2.6.0
- Read and write plans through Api\Plans with PlanView results, replacing Api\SellingPlans
- Scope plan updates to the owning extension and run its validate_plan hook on every write
- Slim the plans REST routes to opaque CRUD and refuse unusable billing cadences when building a BillingPolicy
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-plans-as-records b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-plans-as-records
new file mode 100644
index 00000000000..01ce2372a16
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-plans-as-records
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Subscriptions engine package is not released yet; the plans-as-records rework needs no changelog entry.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
index e14b0b36028..6ba3ae6a144 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -366,7 +366,7 @@ final class Contracts {
* authenticated user at the REST boundary), never inferred, so it never returns
* another customer's contracts. Each view projects the stored contract fields (items
* and addresses not loaded); a caller needing plan terms resolves `selling_plan_id`
- * through {@see SellingPlans}.
+ * through {@see Plans::get()}.
*
* The status filter applies before paging, so a page holds `$limit` matching contracts.
*
@@ -402,7 +402,7 @@ final class Contracts {
* contract it does not own.
*
* The returned view projects the stored contract fields with items and addresses; a
- * caller needing plan terms resolves `selling_plan_id` through {@see SellingPlans}.
+ * caller needing plan terms resolves `selling_plan_id` through {@see Plans::get()}.
*
* @param int $contract_id Contract id.
* @param int $customer_id Customer that must own the contract.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
new file mode 100644
index 00000000000..78db1c0d60f
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * PlanValidationException - a plan write refused by the owning extension's plan validation.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use InvalidArgumentException;
+use WP_Error;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown by {@see Plans} when a `woocommerce_subscriptions_engine_validate_plan`
+ * callback adds errors. Carries the collected errors with their codes and data;
+ * the message joins the error messages.
+ */
+final class PlanValidationException extends InvalidArgumentException {
+
+ /**
+ * Collected validation errors.
+ *
+ * @var WP_Error
+ */
+ private $errors;
+
+ /**
+ * Wrap the collected validation errors.
+ *
+ * @param WP_Error $errors Validation errors.
+ */
+ public function __construct( WP_Error $errors ) {
+ parent::__construct( esc_html( implode( ' ', $errors->get_error_messages() ) ) );
+
+ $this->errors = $errors;
+ }
+
+ /**
+ * The validation errors, with their codes and data.
+ */
+ public function get_errors(): WP_Error {
+ return $this->errors;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
new file mode 100644
index 00000000000..667d2801fbc
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -0,0 +1,397 @@
+<?php
+/**
+ * Plans - the engine's public plan facade (reads and writes).
+ *
+ * Extensions create, update and read plans through explicit argument arrays: the engine records
+ * the payloads it is given and interprets none of them. It checks integrity only (a
+ * non-empty name, a registered status, object-shaped policies), then lets the plan's
+ * owning extension validate the write through `woocommerce_subscriptions_engine_validate_plan`.
+ * Any caller may read any plan; an update names the plan's owning extension and never
+ * reaches a plan of another one (authorization is the caller's concern). Reads return
+ * read-only {@see PlanView}s. The engine opens no transaction and keeps no cache.
+ *
+ * Billing payload contract: the engine reads one plan payload itself. Until every
+ * contract carries a plan snapshot, renewal and reactivation fall back to the live
+ * plan's `billing_policy` and read it with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}
+ * (a string `period` of day, week, month or year, a positive int `interval`, and
+ * optional cycle bounds and trial). A payload of another shape is still stored, but
+ * renewal parks such a contract and reactivation rolls it without a cadence. The
+ * snapshot's `billing_policy` is read the same way, and one that fails the rule falls
+ * back to the live plan.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use DomainException;
+use InvalidArgumentException;
+use RuntimeException;
+use Throwable;
+use WP_Error;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public plan facade: reads and writes.
+ *
+ * Final and static-only: a stateless entry point, not an extension seam.
+ */
+final class Plans {
+
+ /**
+ * Keys accepted by {@see self::create()} and {@see self::update()}, as a key map. The
+ * `extension_slug` sets the owning extension on create and scopes the write on update; it is never
+ * written on update. The other keys are the plan fields.
+ *
+ * @var array<string, true>
+ */
+ private const PLAN_KEYS = array(
+ 'extension_slug' => true,
+ 'name' => true,
+ 'status' => true,
+ 'billing_policy' => true,
+ 'pricing_policy' => true,
+ 'delivery_policy' => true,
+ );
+
+ /**
+ * Keys accepted by {@see self::list()}, as a key map.
+ *
+ * @var array<string, true>
+ */
+ private const LIST_KEYS = array(
+ 'extension_slug' => true,
+ 'status' => true,
+ 'ids' => true,
+ 'limit' => true,
+ 'offset' => true,
+ );
+
+ /**
+ * Default `limit` of {@see self::list()}.
+ */
+ private const DEFAULT_LIST_LIMIT = 200;
+
+ /**
+ * Logger source.
+ */
+ private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
+ // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- create() and update() also throw RuntimeException indirectly, through validate_with_extension() and the repository.
+ /**
+ * Create a plan.
+ *
+ * Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+ *
+ * @param array<string, mixed> $args Plan fields: `extension_slug` (required, the owning
+ * extension), `name` (required, non-empty), `status` (a
+ * registered plan status, default `active`), and
+ * `billing_policy`, `pricing_policy`, `delivery_policy`
+ * (string-keyed arrays the owning extension interprets, or null).
+ * @return PlanView The new plan, built from the written fields (no re-read).
+ * @throws InvalidArgumentException If a required key is missing or a value is invalid, or a
+ * {@see PlanValidationException} when the owning extension refuses the plan.
+ * @throws RuntimeException If a validation callback throws (the callback's throwable is chained
+ * as the previous exception) or the insert fails (no previous exception).
+ */
+ public static function create( array $args ): PlanView {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
+ $extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+ unset( $filtered_args['extension_slug'] );
+
+ try {
+ // The entity requires a name on create; apply() then sets every field, the name included.
+ $plan = Plan::create(
+ array(
+ 'extension_slug' => $extension_slug,
+ 'name' => ArgumentValidator::validate_string( 'name', $filtered_args['name'] ?? '' ),
+ )
+ );
+ self::apply( $plan, $filtered_args );
+ } catch ( DomainException $e ) {
+ throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ }
+
+ self::validate_with_extension( $plan );
+
+ ( new PlanRepository() )->insert( $plan );
+
+ return PlanView::from_plan( $plan );
+ }
+
+ /**
+ * Write the given fields to an existing plan of the given extension.
+ *
+ * Takes the keys of {@see self::create()}. `extension_slug` is required, as on create,
+ * but here it scopes the write to that extension and is never written (a plan's extension never changes):
+ * the plan is read and written only where its row carries that slug, so a plan of
+ * another extension reads as missing and is never written. Only the columns of the
+ * present plan fields are written, so fields a concurrent writer changed in between
+ * keep its values. A present policy key replaces the whole payload (null clears it);
+ * policies are never merged. Re-sending the stored status is accepted even when that
+ * status is no longer registered (its extension was deactivated). Args with no plan
+ * field return the stored plan without validating or writing. Unknown keys raise a
+ * `_doing_it_wrong()` notice and are ignored. The engine opens no transaction: wrap
+ * the call in one when it must be atomic with other writes.
+ *
+ * @param int $plan_id Plan id.
+ * @param array<string, mixed> $args `extension_slug` (required, the owning extension)
+ * and the fields to write.
+ * @return PlanView|null The row as read before the write plus the written fields (a column
+ * another writer changed meanwhile may be stale here, not in storage);
+ * null when no plan of that extension has the id (also when it is
+ * deleted before the write).
+ * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid, or a
+ * {@see PlanValidationException} when the owning extension refuses the plan.
+ * @throws RuntimeException If a validation callback throws (the callback's throwable is chained
+ * as the previous exception) or the update fails (no previous exception).
+ */
+ public static function update( int $plan_id, array $args ): ?PlanView {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
+ $extension_slug = ArgumentValidator::validate_non_empty_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+ unset( $filtered_args['extension_slug'] );
+
+ $repository = new PlanRepository();
+ $plan = $repository->find( $plan_id, $extension_slug );
+ if ( null === $plan ) {
+ return null;
+ }
+
+ $fields = array_keys( $filtered_args );
+ if ( array() === $fields ) {
+ return PlanView::from_plan( $plan );
+ }
+
+ try {
+ self::apply( $plan, $filtered_args );
+ } catch ( DomainException $e ) {
+ throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ }
+
+ self::validate_with_extension( $plan );
+
+ if ( ! $repository->update_fields( $plan, $fields ) ) {
+ return null;
+ }
+
+ return PlanView::from_plan( $plan );
+ }
+
+ // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+ /**
+ * Add a meta value to a plan, like `add_post_meta()`. A key may hold several values.
+ * The plan is not looked up: meta for an unknown plan id is a caller error.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @param mixed $value Meta value; serialized when not scalar.
+ * @param bool $unique When true, add nothing if the key already exists. Advisory: checked
+ * before the insert with no unique index, so concurrent adds can both write.
+ * @return int|null The meta row id; null when `$unique` and the key exists.
+ * @throws InvalidArgumentException If `$key` is empty.
+ */
+ public static function add_meta( int $plan_id, string $key, $value, bool $unique = false ): ?int {
+ return ( new PlanRepository() )->add_meta( $plan_id, $key, $value, $unique );
+ }
+
+ /**
+ * Update a plan's meta values for `$key`, like `update_post_meta()`: adds the key
+ * when absent, else rewrites every value, or only the values equal to `$prev_value`.
+ * The absent-key check runs before the write with no unique index, so it is not a lock.
+ * The plan is not looked up: meta for an unknown plan id is a caller error.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @param mixed $value New value; serialized when not scalar.
+ * @param mixed $prev_value Only update values equal to this; null updates all. Any other
+ * value ('' and false included) matches literally.
+ * @return bool True when a value was added or changed; false when nothing changed.
+ * @throws InvalidArgumentException If `$key` is empty.
+ */
+ public static function update_meta( int $plan_id, string $key, $value, $prev_value = null ): bool {
+ return ( new PlanRepository() )->update_meta( $plan_id, $key, $value, $prev_value );
+ }
+
+ /**
+ * Delete a plan's meta values for `$key`, like `delete_post_meta()`.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @param mixed $value Only delete values equal to this; null deletes every value for the key.
+ * Any other value ('' and false included) matches literally.
+ * @return bool True when at least one value was deleted.
+ * @throws InvalidArgumentException If `$key` is empty.
+ */
+ public static function delete_meta( int $plan_id, string $key, $value = null ): bool {
+ return ( new PlanRepository() )->delete_meta( $plan_id, $key, $value );
+ }
+
+ /**
+ * Read plan meta (WordPress `get_post_meta()` semantics), oldest value first.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key; empty for every key.
+ * @param bool $single With a key: return the first value only.
+ * @return mixed Empty key: values grouped by key. Key + `$single`: the first value, or ''
+ * when absent. Key only: the list of values (`[]` when absent).
+ */
+ public static function get_meta( int $plan_id, string $key = '', bool $single = false ) {
+ return ( new PlanRepository() )->get_meta( $plan_id, $key, $single );
+ }
+
+ /**
+ * Fetch a plan by id, in any status.
+ *
+ * @param int $plan_id Plan id.
+ * @return PlanView|null The plan, or null when none exists.
+ */
+ public static function get( int $plan_id ): ?PlanView {
+ $plan = ( new PlanRepository() )->find( $plan_id );
+
+ return null === $plan ? null : PlanView::from_plan( $plan );
+ }
+
+ /**
+ * List plans, oldest id first. Without args: every extension's plans in every status.
+ *
+ * Archived plans are never purged and count toward `limit`, so pass `status` (for
+ * example `active`) when reading a catalog to sell from. An empty list filter matches
+ * nothing. Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+ *
+ * @param array<string, mixed> $args {
+ * Optional. Query args.
+ *
+ * @type string|string[] $extension_slug Owning extension slug, or a list of them (duplicates are
+ * ignored; `any` is refused). Absent: every extension.
+ * @type string|string[] $status Plan status, or a list of them. Absent: every status.
+ * @type int[] $ids Only these plan ids: a list of positive integers (digit
+ * strings are cast). A non-list or a non-positive id throws.
+ * @type int $limit Maximum plans to return, a positive integer. Default 200.
+ * @type int $offset Plans to skip (for paging), a non-negative integer. Default 0.
+ * }
+ * @return array<int, PlanView>
+ * @throws InvalidArgumentException If a value is invalid: an empty or non-string slug or status,
+ * `any` as a slug, a non-positive or non-integer id, limit or offset.
+ */
+ public static function list( array $args = array() ): array {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::LIST_KEYS );
+
+ $query = array(
+ 'orderby' => 'id',
+ 'order' => 'asc',
+ 'limit' => ArgumentValidator::validate_nullable_id( 'limit', $filtered_args['limit'] ?? null ) ?? self::DEFAULT_LIST_LIMIT,
+ 'offset' => ArgumentValidator::validate_non_negative_int( 'offset', $filtered_args['offset'] ?? 0 ),
+ );
+ if ( array_key_exists( 'extension_slug', $filtered_args ) ) {
+ $extension_slugs = array_values( array_unique( ArgumentValidator::validate_string_list( 'extension_slug', $filtered_args['extension_slug'] ) ) );
+ if ( in_array( 'any', $extension_slugs, true ) ) {
+ throw new InvalidArgumentException( '"extension_slug" must not be "any": leave it out to list every extension\'s plans.' );
+ }
+ $query['extension_slugs'] = $extension_slugs;
+ }
+ if ( array_key_exists( 'status', $filtered_args ) ) {
+ $query['status'] = ArgumentValidator::validate_string_list( 'status', $filtered_args['status'] );
+ }
+ if ( array_key_exists( 'ids', $filtered_args ) ) {
+ $query['ids'] = ArgumentValidator::validate_id_list( 'ids', $filtered_args['ids'] );
+ }
+
+ return array_map(
+ static function ( Plan $plan ): PlanView {
+ return PlanView::from_plan( $plan );
+ },
+ ( new PlanRepository() )->query( $query )
+ );
+ }
+
+ // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
+ /**
+ * Validate the caller's field shapes and apply them to a plan through its setters,
+ * which enforce the entity invariants. Nothing is written to storage; an invalid value
+ * throws before any write.
+ *
+ * @param Plan $plan Plan to change.
+ * @param array<string, mixed> $args Caller fields (known keys only, no `extension_slug`).
+ * @throws InvalidArgumentException If a value has the wrong shape.
+ * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+ */
+ private static function apply( Plan $plan, array $args ): void {
+ foreach ( $args as $key => $value ) {
+ switch ( $key ) {
+ case 'name':
+ $plan->set_name( trim( ArgumentValidator::validate_string( $key, $value ) ) );
+ break;
+ case 'status':
+ $plan->set_status( ArgumentValidator::validate_string( $key, $value ) );
+ break;
+ case 'billing_policy':
+ $plan->set_billing_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
+ break;
+ case 'pricing_policy':
+ $plan->set_pricing_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
+ break;
+ case 'delivery_policy':
+ $plan->set_delivery_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
+ break;
+ }
+ }
+ }
+ // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+ /**
+ * Let the plan's extension validate the would-be plan before it is written.
+ *
+ * @param Plan $plan The would-be plan (unsaved on create).
+ * @throws PlanValidationException If a callback added errors.
+ * @throws RuntimeException If a callback threw, with the callback's throwable as the previous
+ * exception. The REST controller relies on that to tell it from a failed write.
+ */
+ private static function validate_with_extension( Plan $plan ): void {
+ $errors = new WP_Error();
+ $extension_slug = (string) $plan->get_extension_slug();
+
+ try {
+ /**
+ * Fires before every plan write (PHP facade or REST), on create and on update,
+ * including status-only updates, so the plan's extension can refuse it.
+ *
+ * Add errors to $errors to refuse the write; act only on your own $extension_slug.
+ * The view is the would-be state after the write (its id is 0 on create) and is
+ * read-only.
+ *
+ * Changed with plans as records: `$plan` is a read-only {@see PlanView}. Earlier
+ * engine versions passed the Core `Plan` entity; a callback still typed on `Plan`
+ * throws here, which refuses every plan write, so update such callbacks together
+ * with this engine version.
+ *
+ * @param WP_Error $errors Error collector.
+ * @param PlanView $plan The would-be plan.
+ * @param string $extension_slug Owning extension slug.
+ */
+ do_action( 'woocommerce_subscriptions_engine_validate_plan', $errors, PlanView::from_plan( $plan ), $extension_slug );
+ } catch ( Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'Plans: plan validation for extension "%s" (plan %s) threw: %s', $extension_slug, null === $plan->get_id() ? 'new' : (string) $plan->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'extension_slug' => $extension_slug,
+ 'plan_id' => $plan->get_id(),
+ )
+ );
+
+ throw new RuntimeException( 'Plans: the plan could not be validated.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Literal message.
+ }
+
+ if ( $errors->has_errors() ) {
+ throw new PlanValidationException( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The exception escapes its message.
+ }
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
index 8e3ef301141..54994231399 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -1,6 +1,8 @@
<?php
/**
- * REST controller for subscription engine plans.
+ * REST controller for subscription engine plans: opaque CRUD over the plan facade (the
+ * paged, searchable collection reads the repository). Policies pass through as JSON
+ * objects, never parsed or merged.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest
*/
@@ -9,13 +11,16 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
use InvalidArgumentException;
-use Throwable;
+use RuntimeException;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
@@ -37,8 +42,25 @@ final class PlansController extends WP_REST_Controller {
private const DEFAULT_PER_PAGE = 20;
+ /**
+ * Writable plan fields (the owning `extension_slug` comes from the request).
+ *
+ * @var array<int, string>
+ */
+ private const WRITE_FIELDS = array( 'name', 'status', 'billing_policy', 'pricing_policy', 'delivery_policy' );
+
+ /**
+ * Logger source.
+ */
private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+ /**
+ * Columns the collection may be ordered by.
+ *
+ * @var array<int, string>
+ */
+ private const ORDERBY = array( 'id', 'name', 'date_created_gmt', 'date_updated_gmt' );
+
/**
* Plans repository.
*
@@ -113,17 +135,17 @@ final class PlansController extends WP_REST_Controller {
'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 ),
+ 'description' => __( 'Status of the plans to query (any registered plan status).', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'string',
+ 'required' => false,
+ 'validate_callback' => array( $this, 'validate_status_param' ),
),
'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',
+ 'enum' => self::ORDERBY,
+ 'default' => 'id',
),
'order' => array(
'description' => __( 'Order direction for the plan query.', 'woocommerce-subscriptions-engine' ),
@@ -144,18 +166,6 @@ final class PlansController extends WP_REST_Controller {
)
);
- 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]+)',
@@ -191,6 +201,24 @@ final class PlansController extends WP_REST_Controller {
return $this->rest_permissions->require_admin_permission();
}
+ /**
+ * Validate a status param: any registered plan status.
+ *
+ * @param mixed $value Param value.
+ * @return true|WP_Error
+ */
+ public function validate_status_param( $value ) {
+ if ( is_string( $value ) && PlanStatus::is_registered( $value ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_invalid_param',
+ __( 'status must be a registered plan status.', 'woocommerce-subscriptions-engine' ),
+ array( 'status' => 400 )
+ );
+ }
+
/**
* Get a paginated plan list.
*
@@ -204,7 +232,7 @@ final class PlansController extends WP_REST_Controller {
}
$page = max( 1, Coercion::coerce_int( $request->get_param( 'page' ), 1 ) );
- $per_page = $this->resolve_per_page( $request );
+ $per_page = $this->get_per_page( $request );
$args = array(
'limit' => $per_page,
'offset' => ( $page - 1 ) * $per_page,
@@ -225,7 +253,7 @@ final class PlansController extends WP_REST_Controller {
array_map(
function ( Plan $plan ) use ( $request ): array {
$prepared = $this->prepare_response_for_collection(
- $this->prepare_item_for_response( $plan, $request )
+ $this->prepare_item_for_response( PlanView::from_plan( $plan ), $request )
);
return is_array( $prepared ) ? $prepared : array();
@@ -251,8 +279,8 @@ final class PlansController extends WP_REST_Controller {
return $extension_slug;
}
- $plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
- if ( ! $plan instanceof Plan ) {
+ $plan = Plans::get( Coercion::coerce_int( $request->get_param( 'id' ) ) );
+ if ( null === $plan || $extension_slug !== $plan->get_extension_slug() ) {
return $this->not_found_error();
}
@@ -260,7 +288,7 @@ final class PlansController extends WP_REST_Controller {
}
/**
- * Create one global plan.
+ * Create one plan owned by the request's extension slug.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
@@ -271,40 +299,17 @@ final class PlansController extends WP_REST_Controller {
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' ) );
- }
+ $args = $this->get_write_args( $request );
+ $args['extension_slug'] = $extension_slug;
try {
- $billing_policy = $this->associative_array( $billing_policy, 'billing_policy must be an object.' );
-
- $plan = Plan::create(
- array(
- 'name' => $name,
- 'description' => $this->nullable_string_param( $request, 'description' ),
- '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' => Coercion::coerce_int( $request->get_param( 'sort_order' ) ),
- 'extension_slug' => $extension_slug,
- )
- );
-
- $errors = $this->validate_with_owner( $plan, $extension_slug );
- if ( is_wp_error( $errors ) ) {
- return $this->as_bad_request( $errors );
- }
-
- $this->plan_repository->insert( $plan );
- } catch ( Throwable $e ) {
+ $plan = Plans::create( $args );
+ } catch ( PlanValidationException $e ) {
+ return $this->as_bad_request( $e->get_errors() );
+ } catch ( InvalidArgumentException $e ) {
return $this->invalid_error( $e->getMessage() );
+ } catch ( RuntimeException $e ) {
+ return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_create_failed' );
}
$response = rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
@@ -314,7 +319,8 @@ final class PlansController extends WP_REST_Controller {
}
/**
- * Partially update a plan.
+ * Partially update a plan: only the present fields are written, and a present
+ * policy replaces the stored payload.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
@@ -325,130 +331,45 @@ final class PlansController extends WP_REST_Controller {
return $extension_slug;
}
- $plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
- if ( ! $plan instanceof Plan ) {
- return $this->not_found_error();
- }
+ $args = $this->get_write_args( $request );
+ $args['extension_slug'] = $extension_slug;
try {
- 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 );
- }
-
- 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( Coercion::coerce_int( $request->get_param( 'sort_order' ) ) );
- }
-
- $errors = $this->validate_with_owner( $plan, $extension_slug );
- if ( is_wp_error( $errors ) ) {
- return $this->as_bad_request( $errors );
- }
-
- if ( ! $this->plan_repository->update( $plan ) ) {
- return new WP_Error(
- 'woocommerce_subscriptions_engine_plan_update_failed',
- __( 'The plan could not be saved.', 'woocommerce-subscriptions-engine' ),
- array( 'status' => 500 )
- );
- }
- } catch ( Throwable $e ) {
+ // A plan of another extension reads as missing: the facade scopes the update to the request's extension slug.
+ $plan = Plans::update( Coercion::coerce_int( $request->get_param( 'id' ) ), $args );
+ } catch ( PlanValidationException $e ) {
+ return $this->as_bad_request( $e->get_errors() );
+ } catch ( InvalidArgumentException $e ) {
return $this->invalid_error( $e->getMessage() );
+ } catch ( RuntimeException $e ) {
+ return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_update_failed' );
}
- 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 = Coercion::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 )
- );
+ if ( null === $plan ) {
+ return $this->not_found_error();
}
- return rest_ensure_response( array( 'ids' => $response_ids ) );
+ return rest_ensure_response( $this->prepare_item_for_response( $plan, $request ) );
}
/**
- * Serialize a plan.
+ * Serialize a plan view.
*
- * @param Plan $item Plan.
+ * @param PlanView $item Plan view.
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $item, $request ) {
$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' => $item->get_pricing_policy(),
+ 'id' => $item->get_id(),
+ 'extension_slug' => $item->get_extension_slug(),
+ 'status' => $item->get_status(),
+ 'name' => $item->get_name(),
+ 'billing_policy' => self::as_json_object( $item->get_billing_policy() ),
+ 'pricing_policy' => self::as_json_object( $item->get_pricing_policy() ),
+ 'delivery_policy' => self::as_json_object( $item->get_delivery_policy() ),
+ 'date_created_gmt' => $item->get_date_created_gmt(),
+ 'date_updated_gmt' => $item->get_date_updated_gmt(),
);
$context = Coercion::coerce_string( $request->get_param( 'context' ), 'view' );
@@ -489,16 +410,15 @@ final class PlansController extends WP_REST_Controller {
'sanitize_callback' => 'sanitize_text_field',
),
'status' => array(
- 'description' => __( 'Limit result set to plans with a status.', 'woocommerce-subscriptions-engine' ),
+ 'description' => __( 'Limit result set to plans with a registered plan status.', 'woocommerce-subscriptions-engine' ),
'type' => 'string',
- 'enum' => Plan::ALLOWED_STATUSES,
- 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => array( $this, 'validate_status_param' ),
),
'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' ),
+ 'default' => 'id',
+ 'enum' => self::ORDERBY,
'sanitize_callback' => 'sanitize_key',
),
'order' => array(
@@ -527,54 +447,57 @@ final class PlansController extends WP_REST_Controller {
'title' => 'subscription_engine_plan',
'type' => 'object',
'properties' => array(
- 'id' => 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' ),
+ 'extension_slug' => array(
+ 'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
'type' => array( 'string', 'null' ),
'context' => array( 'view', 'edit' ),
),
- 'scope' => array(
- 'description' => __( 'Plan scope.', 'woocommerce-subscriptions-engine' ),
+ 'status' => array(
+ 'description' => __( 'Plan status (any registered plan status).', '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' ),
+ 'arg_options' => array(
+ 'validate_callback' => array( $this, 'validate_status_param' ),
+ ),
),
- 'sort_order' => array(
- 'description' => __( 'Manual sort order.', 'woocommerce-subscriptions-engine' ),
- 'type' => 'integer',
+ 'name' => array(
+ 'description' => __( 'Display name.', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'string',
'context' => array( 'view', 'edit' ),
),
- 'extension_slug' => array(
- 'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
- 'type' => array( 'string', 'null' ),
+ 'billing_policy' => array(
+ 'description' => __( 'Billing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
+ 'type' => array( 'object', 'null' ),
'context' => array( 'view', 'edit' ),
),
- 'billing_policy' => array(
- 'description' => __( 'Billing policy.', 'woocommerce-subscriptions-engine' ),
- 'type' => 'object',
+ 'pricing_policy' => array(
+ 'description' => __( 'Pricing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
+ 'type' => array( 'object', 'null' ),
'context' => array( 'view', 'edit' ),
),
- 'pricing_policy' => array(
- 'description' => __( 'Pricing policy.', 'woocommerce-subscriptions-engine' ),
+ 'delivery_policy' => array(
+ 'description' => __( 'Delivery payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
'type' => array( 'object', 'null' ),
'context' => array( 'view', 'edit' ),
),
+ 'date_created_gmt' => array(
+ 'description' => __( 'Creation time (GMT).', 'woocommerce-subscriptions-engine' ),
+ 'type' => array( 'string', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'date_updated_gmt' => array(
+ 'description' => __( 'Last update time (GMT).', 'woocommerce-subscriptions-engine' ),
+ 'type' => array( 'string', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
),
);
@@ -582,11 +505,11 @@ final class PlansController extends WP_REST_Controller {
}
/**
- * Resolve per_page.
+ * The requested page size: capped at the maximum, and the default when below 1 or not a number.
*
* @param WP_REST_Request $request Request.
*/
- private function resolve_per_page( WP_REST_Request $request ): int {
+ private function get_per_page( WP_REST_Request $request ): int {
$value = Coercion::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
if ( $value < 1 ) {
return self::DEFAULT_PER_PAGE;
@@ -596,62 +519,48 @@ final class PlansController extends WP_REST_Controller {
}
/**
- * Build the pricing payload from a request param. Provided top-level keys
- * replace existing ones; omitted keys keep their stored value.
+ * Collect the present writable params as facade args, passed through as given
+ * (the name is sanitized); the facade validates them.
*
- * @param mixed $value Request value.
- * @param array<string, mixed>|null $existing Existing payload.
- * @return array<string, mixed>|null
- * @throws InvalidArgumentException If the param is not an object or null.
+ * @param WP_REST_Request $request Request.
+ * @return array<string, mixed>
*/
- private function pricing_policy_from_param( $value, ?array $existing ): ?array {
- if ( null === $value ) {
- return null;
- }
-
- if ( ! is_array( $value ) ) {
- throw new InvalidArgumentException( 'pricing_policy must be an object or null.' );
+ private function get_write_args( WP_REST_Request $request ): array {
+ $args = array();
+ foreach ( self::WRITE_FIELDS as $field ) {
+ if ( $request->has_param( $field ) ) {
+ $args[ $field ] = 'name' === $field ? $this->get_string_param( $request, 'name' ) : $request->get_param( $field );
+ }
}
- $value = $this->associative_array( $value, 'pricing_policy must be an object or null.' );
-
- return array_replace( $existing ?? array(), $value );
+ return $args;
}
/**
- * Let the owning extension validate the plan before it is stored. A plan whose
- * owner registers no callback is stored without owner validation.
+ * Present a stored policy as a JSON object: an empty payload serializes as `{}`.
*
- * @param Plan $plan Plan about to be written.
- * @param string $extension_slug Owning extension slug.
- * @return WP_Error|null Errors rejecting the write (500 if a callback threw), or null when valid.
+ * @param array<string, mixed>|null $policy Policy payload.
+ * @return array<string, mixed>|object|null
*/
- private function validate_with_owner( Plan $plan, string $extension_slug ): ?WP_Error {
- $errors = new WP_Error();
+ private static function as_json_object( ?array $policy ) {
+ if ( array() === $policy ) {
+ return (object) array();
+ }
- try {
- /**
- * Fires before a plan is written so the owning extension can validate it.
- *
- * Add errors to $errors to reject the write; act only on your own $extension_slug.
- * The plan is a copy: changes to it are not stored. Runs on create and update
- * (including status-only updates), not on reorder.
- *
- * @param WP_Error $errors Error collector.
- * @param Plan $plan Plan about to be written; its id is null on create.
- * @param string $extension_slug Owning extension slug.
- */
- do_action( 'woocommerce_subscriptions_engine_validate_plan', $errors, clone $plan, $extension_slug );
- } catch ( Throwable $e ) {
- wc_get_logger()->error(
- sprintf( 'PlansController: plan validation for extension "%s" (plan %s) threw: %s', $extension_slug, null === $plan->get_id() ? 'new' : (string) $plan->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'extension_slug' => $extension_slug,
- 'plan_id' => $plan->get_id(),
- )
- );
+ return $policy;
+ }
+ /**
+ * Map a failed write to a 500. The facade wraps a throwing validation callback
+ * (the cause is chained, and the facade logs it); a failed insert or update carries
+ * no cause and is logged here with the database error. `Api\Plans` documents this
+ * on its create and update `@throws`, and PlansTest pins both sides.
+ *
+ * @param RuntimeException $e Failure.
+ * @param string $code Error code for a failed insert or update.
+ */
+ private function write_failed_error( RuntimeException $e, string $code ): WP_Error {
+ if ( $e->getPrevious() instanceof \Throwable ) {
return new WP_Error(
'woocommerce_subscriptions_engine_plan_validation_failed',
__( 'The plan could not be validated.', 'woocommerce-subscriptions-engine' ),
@@ -659,7 +568,16 @@ final class PlansController extends WP_REST_Controller {
);
}
- return $errors->has_errors() ? $errors : null;
+ wc_get_logger()->error(
+ sprintf( 'PlansController: the plan write failed: %s', $e->getMessage() ),
+ array( 'source' => self::LOG_SOURCE )
+ );
+
+ return new WP_Error(
+ $code,
+ __( 'The plan could not be saved.', 'woocommerce-subscriptions-engine' ),
+ array( 'status' => 500 )
+ );
}
/**
@@ -744,51 +662,16 @@ final class PlansController extends WP_REST_Controller {
}
/**
- * Read a string param.
+ * A sanitized 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 {
+ private function get_string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
return sanitize_text_field( Coercion::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 = Coercion::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.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php
deleted file mode 100644
index f795ff5526b..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/SellingPlans.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-/**
- * SellingPlans - the engine's public catalog read facade.
- *
- * The one surface consumers import to read the plans catalog: list an
- * extension's active plans and fetch specific plans by id for selection and
- * display UIs. Which products a plan applies to is consumer-owned - the
- * engine stores the catalog, not product attachment. The facade hides the
- * internal `Integration\` repositories behind a stable boundary, so the
- * internals stay refactorable. Strictly additive-only, like every `Api\`
- * surface.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Api
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Public selling-plans catalog read facade.
- *
- * Each extension constructs one instance scoped to its own slugs and reuses
- * it for every read - the slug scope is fixed at construction so call sites
- * never carry it around. Instances are cheap and hold no state beyond the
- * scope, so constructing more is harmless. Final: a facade over the engine
- * internals, not an extension seam.
- */
-final class SellingPlans {
-
- /**
- * Query limit for plan lookups; high enough that a plan catalog is never
- * truncated by the repository's default of 50.
- *
- * @var int
- */
- private const PLAN_QUERY_LIMIT = 200;
-
- /**
- * Extension slugs this instance reads plans for.
- *
- * @var array<int, string>
- */
- private $extension_slugs;
-
- /**
- * Scope the facade to the calling extension's slugs.
- *
- * @param array<int, string> $extension_slugs Extension slugs to read plans for.
- */
- public function __construct( array $extension_slugs ) {
- $this->extension_slugs = $extension_slugs;
- }
-
- /**
- * List the scoped extensions' active plans in display order - the read
- * behind a plan-selection UI.
- *
- * @return array<int, Plan> Plans in display order.
- */
- public function list_plans(): array {
- return ( new PlanRepository() )->query(
- array(
- 'status' => Plan::STATUS_ACTIVE,
- 'extension_slugs' => $this->extension_slugs,
- 'limit' => self::PLAN_QUERY_LIMIT,
- )
- );
- }
-
- /**
- * Fetch the active plans among the given ids owned by the scoped
- * extensions, in display order - the read behind rendering a stored plan
- * selection.
- *
- * Ids that are unknown, archived, or owned by an out-of-scope extension
- * are simply absent from the result. An empty or invalid id list yields
- * an empty array.
- *
- * @param array<int, int> $plan_ids Plan ids to fetch.
- * @return array<int, Plan> Plans in display order.
- */
- public function get_plans( array $plan_ids ): array {
- return ( new PlanRepository() )->query(
- array(
- 'status' => Plan::STATUS_ACTIVE,
- 'extension_slugs' => $this->extension_slugs,
- 'ids' => $plan_ids,
- 'limit' => self::PLAN_QUERY_LIMIT,
- )
- );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
new file mode 100644
index 00000000000..29a5dec7858
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * PlanView - a read-only view of a selling plan at the `Api\` boundary.
+ *
+ * Consumers read plans through this view instead of the Core entity. Getters may be
+ * added, never removed. The three policies are the owning extension's opaque
+ * payloads, returned as stored. The engine itself reads only `billing_policy`, as
+ * the renewal fallback for contracts without a plan snapshot (see {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans}).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Immutable plan view.
+ */
+final class PlanView {
+
+ /**
+ * Plan row values keyed by field name.
+ *
+ * @var array{id: int, extension_slug: ?string, status: string, name: string, billing_policy: ?array<string, mixed>, pricing_policy: ?array<string, mixed>, delivery_policy: ?array<string, mixed>, date_created_gmt: ?string, date_updated_gmt: ?string}
+ */
+ private $fields;
+
+ /**
+ * Use {@see self::from_plan()}.
+ */
+ private function __construct() {
+ }
+
+ /**
+ * Build a view of a plan. An unsaved plan (the would-be plan a create
+ * validates) has id 0.
+ *
+ * @internal Built by the engine `Api\` facades only.
+ *
+ * @param Plan $plan Plan entity.
+ */
+ public static function from_plan( Plan $plan ): self {
+ $view = new self();
+ $view->fields = array(
+ 'id' => (int) $plan->get_id(),
+ 'extension_slug' => $plan->get_extension_slug(),
+ 'status' => $plan->get_status(),
+ 'name' => $plan->get_name(),
+ 'billing_policy' => $plan->get_billing_policy(),
+ 'pricing_policy' => $plan->get_pricing_policy(),
+ 'delivery_policy' => $plan->get_delivery_policy(),
+ 'date_created_gmt' => $plan->get_date_created_gmt(),
+ 'date_updated_gmt' => $plan->get_date_updated_gmt(),
+ );
+
+ return $view;
+ }
+
+ /**
+ * Plan id; 0 for a plan that is not stored yet.
+ */
+ public function get_id(): int {
+ return $this->fields['id'];
+ }
+
+ /**
+ * Owning extension slug, or null.
+ */
+ public function get_extension_slug(): ?string {
+ return $this->fields['extension_slug'];
+ }
+
+ /**
+ * Plan status slug.
+ */
+ public function get_status(): string {
+ return $this->fields['status'];
+ }
+
+ /**
+ * Display name.
+ */
+ public function get_name(): string {
+ return $this->fields['name'];
+ }
+
+ /**
+ * Billing payload of the owning extension, or null.
+ *
+ * @return array<string, mixed>|null
+ */
+ public function get_billing_policy(): ?array {
+ return $this->fields['billing_policy'];
+ }
+
+ /**
+ * Pricing payload of the owning extension, or null.
+ *
+ * @return array<string, mixed>|null
+ */
+ public function get_pricing_policy(): ?array {
+ return $this->fields['pricing_policy'];
+ }
+
+ /**
+ * Delivery payload of the owning extension, or null.
+ *
+ * @return array<string, mixed>|null
+ */
+ public function get_delivery_policy(): ?array {
+ return $this->fields['delivery_policy'];
+ }
+
+ /**
+ * Creation time (GMT, `Y-m-d H:i:s`), or null for an unsaved plan.
+ */
+ public function get_date_created_gmt(): ?string {
+ return $this->fields['date_created_gmt'];
+ }
+
+ /**
+ * Last update time (GMT, `Y-m-d H:i:s`), or null for an unsaved plan.
+ */
+ public function get_date_updated_gmt(): ?string {
+ return $this->fields['date_updated_gmt'];
+ }
+}
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 c71a6926a51..4e51b175c81 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -1,7 +1,7 @@
<?php
/**
- * Plan - a subscription selling plan: cadence, pricing, and delivery policy for
- * one or more products.
+ * Plan - a stored selling plan record. Its billing, pricing and delivery policies
+ * are opaque payloads of the owning extension; the engine checks their shape only.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
*/
@@ -10,9 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
-use InvalidArgumentException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\DeliveryPolicy;
+use DomainException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -25,16 +23,6 @@ defined( 'ABSPATH' ) || exit;
*/
final class Plan {
- public const DEFAULT_CATEGORY = 'SUBSCRIPTION';
-
- public const DEFAULT_STATUS = 'active';
-
- public const STATUS_ACTIVE = 'active';
-
- public const STATUS_ARCHIVED = 'archived';
-
- public const ALLOWED_STATUSES = array( self::STATUS_ACTIVE, self::STATUS_ARCHIVED );
-
/**
* Plan id, or null before it is persisted.
*
@@ -50,171 +38,106 @@ final class Plan {
private $name;
/**
- * Optional description.
+ * Registered plan status.
*
- * @var string|null
+ * @var string
*/
- private $description;
+ private $status;
/**
- * Billing cadence. Required - every plan has one.
+ * Owning extension slug.
*
- * @var BillingPolicy
+ * @var string|null
*/
- private $billing_policy;
+ private $extension_slug;
/**
- * Optional delivery policy.
+ * Billing payload, owned and interpreted by the plan's extension.
*
- * @var DeliveryPolicy|null
+ * @var array<string, mixed>|null
*/
- private $delivery_policy;
+ private $billing_policy;
/**
- * Optional pricing payload, owned and interpreted by the plan's extension.
+ * Pricing payload, owned and interpreted by the plan's extension.
*
* @var array<string, mixed>|null
*/
private $pricing_policy;
/**
- * Plan category.
- *
- * @var string
- */
- private $category;
-
- /**
- * Merchant lifecycle status.
- *
- * @var string
- */
- private $status;
-
- /**
- * Manual display order.
+ * Delivery payload, owned and interpreted by the plan's extension.
*
- * @var int
+ * @var array<string, mixed>|null
*/
- private $sort_order;
+ private $delivery_policy;
/**
- * Optional stable external identifier, unique at the storage layer - the
- * consumer-side dedup key. Immutable post-create.
+ * Creation time (GMT, `Y-m-d H:i:s`) as stored, or null before insert.
*
* @var string|null
*/
- private $merchant_code;
+ private $date_created_gmt;
/**
- * Owning extension slug, or null until owner semantics are assigned.
+ * Last update time (GMT, `Y-m-d H:i:s`) as stored, or null before insert.
*
* @var string|null
*/
- private $extension_slug;
+ private $date_updated_gmt;
/**
- * Use {@see self::create()} or {@see self::from_storage()}.
+ * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+ * to its property type; unknown keys are ignored, missing keys take the default.
*
- * @param int|null $id Plan id, or null before save.
- * @param string $name Display name.
- * @param string|null $description Optional description.
- * @param BillingPolicy $billing_policy Billing cadence.
- * @param DeliveryPolicy|null $delivery_policy Optional delivery policy.
- * @param array<string, mixed>|null $pricing_policy Optional pricing payload.
- * @param string $category Plan category.
- * @param string $status Merchant lifecycle status.
- * @param int $sort_order Manual display order.
- * @param string|null $merchant_code Optional stable external identifier.
- * @param string|null $extension_slug Owning extension slug.
- */
- private function __construct(
- ?int $id,
- string $name,
- ?string $description,
- BillingPolicy $billing_policy,
- ?DeliveryPolicy $delivery_policy,
- ?array $pricing_policy,
- string $category,
- string $status,
- int $sort_order,
- ?string $merchant_code,
- ?string $extension_slug
- ) {
- self::validate_status( $status );
-
- $this->id = $id;
- $this->name = $name;
- $this->description = $description;
- $this->billing_policy = $billing_policy;
- $this->delivery_policy = $delivery_policy;
- $this->pricing_policy = $pricing_policy;
- $this->category = $category;
- $this->status = $status;
- $this->sort_order = $sort_order;
- $this->merchant_code = $merchant_code;
- $this->extension_slug = $extension_slug;
+ * @param array<string, mixed> $data Raw attributes keyed by property name.
+ */
+ private function __construct( array $data ) {
+ $this->id = Coercion::coerce_nullable_int( $data['id'] ?? null );
+ $this->name = Coercion::coerce_string( $data['name'] ?? null );
+ $this->status = Coercion::coerce_string( $data['status'] ?? null, PlanStatus::ACTIVE );
+ $this->extension_slug = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+ $this->billing_policy = Coercion::coerce_nullable_string_keyed( $data['billing_policy'] ?? null );
+ $this->pricing_policy = Coercion::coerce_nullable_string_keyed( $data['pricing_policy'] ?? null );
+ $this->delivery_policy = Coercion::coerce_nullable_string_keyed( $data['delivery_policy'] ?? null );
+ $this->date_created_gmt = Coercion::coerce_nullable_string( $data['date_created_gmt'] ?? null );
+ $this->date_updated_gmt = Coercion::coerce_nullable_string( $data['date_updated_gmt'] ?? null );
}
/**
- * Build a new, unsaved plan.
+ * Build a new, unsaved plan. `extension_slug` and a non-empty `name` are required.
*
- * @param array<string, mixed> $args Plan attributes.
- * @throws InvalidArgumentException If pricing_policy is not an object (string-keyed array) or null.
+ * @param array<string, mixed> $args Keys `name`, `status` (default {@see PlanStatus::ACTIVE}), `extension_slug`, `billing_policy`, `pricing_policy`, `delivery_policy`.
+ * @throws DomainException If the plan attributes are not valid.
*/
public static function create( array $args ): self {
- $pricing_policy = self::assert_object_or_null( $args['pricing_policy'] ?? null );
+ // A new plan is always unsaved; never adopt a caller-supplied id or stored dates.
+ unset( $args['id'], $args['date_created_gmt'], $args['date_updated_gmt'] );
- $billing_policy = $args['billing_policy'] ?? null;
- if ( ! $billing_policy instanceof BillingPolicy ) {
- throw new InvalidArgumentException( 'Plan: billing_policy is required and must be a BillingPolicy instance.' );
- }
+ // Checked before construction, which coerces a non-array payload to null.
+ self::assert_policy( 'billing_policy', $args['billing_policy'] ?? null );
+ self::assert_policy( 'pricing_policy', $args['pricing_policy'] ?? null );
+ self::assert_policy( 'delivery_policy', $args['delivery_policy'] ?? null );
- $delivery_policy = $args['delivery_policy'] ?? null;
- if ( null !== $delivery_policy && ! $delivery_policy instanceof DeliveryPolicy ) {
- throw new InvalidArgumentException( 'Plan: delivery_policy must be a DeliveryPolicy instance or null.' );
- }
+ $plan = new self( $args );
- return new self(
- null,
- Coercion::coerce_string( $args['name'] ?? null ),
- Coercion::coerce_nullable_string( $args['description'] ?? null ),
- $billing_policy,
- $delivery_policy,
- $pricing_policy,
- Coercion::coerce_string( $args['category'] ?? null, self::DEFAULT_CATEGORY ),
- Coercion::coerce_string( $args['status'] ?? null, self::DEFAULT_STATUS ),
- Coercion::coerce_int( $args['sort_order'] ?? null, 0 ),
- Coercion::coerce_nullable_string( $args['merchant_code'] ?? null ),
- Coercion::coerce_nullable_string( $args['extension_slug'] ?? null )
- );
+ self::assert_name( $plan->name );
+ self::assert_status( $plan->status );
+ self::assert_extension_slug( $plan->extension_slug );
+
+ return $plan;
}
/**
- * Hydrate from a stored row. Policy columns arrive JSON-decoded.
+ * Hydrate from a stored row, without validation. Policy columns arrive JSON-decoded.
*
- * The pricing payload is checked only for shape (object or null); its
- * semantics belong to the owning extension.
+ * The stored status is taken as is: a status registered by a since-deactivated
+ * extension still hydrates.
*
* @param array<string, mixed> $row Decoded plan row.
- * @throws InvalidArgumentException If the stored pricing_policy is not an object.
*/
public static function from_storage( array $row ): self {
- $pricing_policy = self::assert_object_or_null( $row['pricing_policy'] ?? null );
-
- return new self(
- isset( $row['id'] ) ? Coercion::coerce_int( $row['id'] ) : null,
- Coercion::coerce_string( $row['name'] ?? null ),
- Coercion::coerce_nullable_string( $row['description'] ?? null ),
- BillingPolicy::from_array( is_array( $row['billing_policy'] ?? null ) ? $row['billing_policy'] : array() ),
- isset( $row['delivery_policy'] ) && is_array( $row['delivery_policy'] ) ? DeliveryPolicy::from_array( $row['delivery_policy'] ) : null,
- $pricing_policy,
- Coercion::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
- Coercion::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
- Coercion::coerce_int( $row['sort_order'] ?? null, 0 ),
- Coercion::coerce_nullable_string( $row['merchant_code'] ?? null ),
- Coercion::coerce_nullable_string( $row['extension_slug'] ?? null )
- );
+ return new self( $row );
}
/**
@@ -243,62 +166,66 @@ final class Plan {
/**
* Set the display name.
*
- * @param string $name Display name.
+ * @param string $name Display name; must not be empty.
+ * @throws DomainException If the name is empty.
*/
public function set_name( string $name ): void {
+ self::assert_name( $name );
$this->name = $name;
}
/**
- * Optional description.
+ * Plan status.
*/
- public function get_description(): ?string {
- return $this->description;
+ public function get_status(): string {
+ return $this->status;
}
/**
- * Set the description.
+ * Set the plan status. Setting the current status is a no-op, so a hydrated
+ * unregistered status survives it.
*
- * @param string|null $description Description.
+ * @param string $status Plan status; must be registered.
+ * @throws DomainException If the status is not registered.
*/
- public function set_description( ?string $description ): void {
- $this->description = $description;
- }
+ public function set_status( string $status ): void {
+ if ( $status === $this->status ) {
+ return;
+ }
- /**
- * Billing cadence.
- */
- public function get_billing_policy(): BillingPolicy {
- return $this->billing_policy;
+ self::assert_status( $status );
+ $this->status = $status;
}
/**
- * Set the billing cadence.
- *
- * @param BillingPolicy $billing_policy Billing cadence.
+ * Owning extension slug, or null.
*/
- public function set_billing_policy( BillingPolicy $billing_policy ): void {
- $this->billing_policy = $billing_policy;
+ public function get_extension_slug(): ?string {
+ return $this->extension_slug;
}
/**
- * Optional delivery policy.
+ * Billing payload, as stored.
+ *
+ * @return array<string, mixed>|null
*/
- public function get_delivery_policy(): ?DeliveryPolicy {
- return $this->delivery_policy;
+ public function get_billing_policy(): ?array {
+ return $this->billing_policy;
}
/**
- * Set the delivery policy.
+ * Replace the billing payload.
*
- * @param DeliveryPolicy|null $delivery_policy Delivery policy.
+ * @param array<array-key, mixed>|null $billing_policy Billing payload; must be string-keyed.
+ * @throws DomainException If the payload is not an object (string-keyed array).
*/
- public function set_delivery_policy( ?DeliveryPolicy $delivery_policy ): void {
- $this->delivery_policy = $delivery_policy;
+ public function set_billing_policy( ?array $billing_policy ): void {
+ self::assert_policy( 'billing_policy', $billing_policy );
+ $this->billing_policy = Coercion::coerce_nullable_string_keyed( $billing_policy );
}
/**
- * Optional pricing payload, as stored.
+ * Pricing payload, as stored.
*
* @return array<string, mixed>|null
*/
@@ -307,142 +234,139 @@ final class Plan {
}
/**
- * Set the pricing payload.
+ * Replace the pricing payload.
*
- * @param array<string, mixed>|null $pricing_policy Pricing payload.
- * @throws InvalidArgumentException If pricing_policy is not an object (string-keyed array).
+ * @param array<array-key, mixed>|null $pricing_policy Pricing payload; must be string-keyed.
+ * @throws DomainException If the payload is not an object (string-keyed array).
*/
public function set_pricing_policy( ?array $pricing_policy ): void {
- $this->pricing_policy = self::assert_object_or_null( $pricing_policy );
+ self::assert_policy( 'pricing_policy', $pricing_policy );
+ $this->pricing_policy = Coercion::coerce_nullable_string_keyed( $pricing_policy );
}
/**
- * Plan category.
- */
- public function get_category(): string {
- return $this->category;
- }
-
- /**
- * Set the plan category.
+ * Delivery payload, as stored.
*
- * @param string $category Plan category.
- */
- public function set_category( string $category ): void {
- $this->category = $category;
- }
-
- /**
- * Merchant lifecycle status.
+ * @return array<string, mixed>|null
*/
- public function get_status(): string {
- return $this->status;
+ public function get_delivery_policy(): ?array {
+ return $this->delivery_policy;
}
/**
- * Set the merchant lifecycle status.
+ * Replace the delivery payload.
*
- * @param string $status Plan status.
- * @throws InvalidArgumentException If the status is unknown.
+ * @param array<array-key, mixed>|null $delivery_policy Delivery payload; must be string-keyed.
+ * @throws DomainException If the payload is not an object (string-keyed array).
*/
- public function set_status( string $status ): void {
- self::validate_status( $status );
- $this->status = $status;
+ public function set_delivery_policy( ?array $delivery_policy ): void {
+ self::assert_policy( 'delivery_policy', $delivery_policy );
+ $this->delivery_policy = Coercion::coerce_nullable_string_keyed( $delivery_policy );
}
/**
- * Manual display order.
+ * Creation time (GMT) as stored, or null before insert.
*/
- public function get_sort_order(): int {
- return $this->sort_order;
+ public function get_date_created_gmt(): ?string {
+ return $this->date_created_gmt;
}
/**
- * Set the manual display order.
+ * Assign the creation time after a successful insert.
*
- * @param int $sort_order Sort order.
+ * @param string $date_created_gmt Stored creation time (GMT, `Y-m-d H:i:s`).
*/
- public function set_sort_order( int $sort_order ): void {
- $this->sort_order = $sort_order;
+ public function set_date_created_gmt( string $date_created_gmt ): void {
+ $this->date_created_gmt = $date_created_gmt;
}
/**
- * Optional stable external identifier, or null. Immutable post-create.
+ * Last update time (GMT) as stored, or null before insert.
*/
- public function get_merchant_code(): ?string {
- return $this->merchant_code;
+ public function get_date_updated_gmt(): ?string {
+ return $this->date_updated_gmt;
}
/**
- * Owning extension slug, or null.
+ * Assign the update time after a successful write.
+ *
+ * @param string $date_updated_gmt Stored update time (GMT, `Y-m-d H:i:s`).
*/
- public function get_extension_slug(): ?string {
- return $this->extension_slug;
+ public function set_date_updated_gmt( string $date_updated_gmt ): void {
+ $this->date_updated_gmt = $date_updated_gmt;
}
/**
- * Serialize to the storage column shape (excluding generated id/timestamps).
- *
- * Policy value objects are returned as arrays and the pricing payload as
- * stored; the repository JSON-encodes them.
+ * Serialize to the storage column shape (excluding the id and timestamps).
+ * Policies are returned as arrays or null; the repository JSON-encodes them.
*
* @return array<string, mixed>
*/
public function to_storage(): array {
return array(
'name' => $this->name,
- 'description' => $this->description,
- 'billing_policy' => $this->billing_policy->to_array(),
- 'delivery_policy' => null !== $this->delivery_policy ? $this->delivery_policy->to_array() : null,
- 'pricing_policy' => $this->pricing_policy,
- 'category' => $this->category,
'status' => $this->status,
- 'sort_order' => $this->sort_order,
- 'merchant_code' => $this->merchant_code,
'extension_slug' => $this->extension_slug,
+ 'billing_policy' => $this->billing_policy,
+ 'pricing_policy' => $this->pricing_policy,
+ 'delivery_policy' => $this->delivery_policy,
);
}
/**
- * Validate a plan lifecycle status.
+ * Refuse an empty or whitespace-only name.
*
- * @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 )
- );
+ * @param string $name Name to check.
+ * @throws DomainException If `$name` is empty.
+ */
+ private static function assert_name( string $name ): void {
+ if ( '' === trim( $name ) ) {
+ throw new DomainException( 'Plan: name is required and must be a non-empty string.' );
}
}
/**
- * Accept a pricing payload only as an object (string-keyed array) or null.
- * An empty array is accepted (a JSON `{}` decodes to it).
+ * Refuse a status that is not a registered plan status.
*
- * @param mixed $value Candidate payload.
- * @return array<string, mixed>|null
- * @throws InvalidArgumentException If the value is a list or not an array.
+ * @param string $status Status to check.
+ * @throws DomainException If `$status` is not registered.
*/
- private static function assert_object_or_null( $value ): ?array {
- if ( null === $value ) {
- return null;
+ private static function assert_status( string $status ): void {
+ if ( ! PlanStatus::is_registered( $status ) ) {
+ throw new DomainException( sprintf( 'Plan: status "%s" is not registered.', $status ) );
}
+ }
- $message = 'Plan: pricing_policy must be an object (string-keyed array) or null.';
- if ( ! is_array( $value ) ) {
- throw new InvalidArgumentException( $message );
+ /**
+ * Refuse a missing or empty owning extension slug.
+ *
+ * @param string|null $extension_slug Extension slug to check.
+ * @throws DomainException If `$extension_slug` is null or empty.
+ */
+ private static function assert_extension_slug( ?string $extension_slug ): void {
+ if ( null === $extension_slug || '' === $extension_slug ) {
+ throw new DomainException( 'Plan: extension_slug is required and must be a non-empty string.' );
}
+ }
- $out = array();
- foreach ( $value as $key => $item ) {
- if ( ! is_string( $key ) ) {
- throw new InvalidArgumentException( $message );
- }
- $out[ $key ] = $item;
+ /**
+ * Refuse a policy payload that is not an object or null. Any keyed array is an object,
+ * including numeric keys (a JSON `{"123": ...}` decodes to an int key); only a list (keys
+ * 0..n-1), which would come back as a JSON array, is refused. An empty array is accepted
+ * (a JSON `{}` decodes to it). The same rule for each of the three policies.
+ *
+ * @param string $field Policy field name, for the error message.
+ * @param mixed $value Candidate payload.
+ * @throws DomainException If the value is a non-empty list or not an array.
+ */
+ private static function assert_policy( string $field, $value ): void {
+ if ( null === $value ) {
+ return;
}
- return $out;
+ $is_list = is_array( $value ) && array() !== $value && array_keys( $value ) === range( 0, count( $value ) - 1 );
+ if ( ! is_array( $value ) || $is_list ) {
+ throw new DomainException( sprintf( 'Plan: %s must be an object or null.', $field ) );
+ }
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanStatus.php
new file mode 100644
index 00000000000..848a91085d8
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanStatus.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * PlanStatus - the engine's default plan status slugs plus read helpers over
+ * the {@see StatusRegistry}.
+ *
+ * Plan status is opaque engine data. The defaults are shared slugs and carry no
+ * engine meaning; the engine enforces no transitions. Extensions may register
+ * more through {@see StatusRegistry::register()}. The {@see Plan} entity refuses
+ * to write a status that is not registered.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * PlanStatus value/helper class.
+ */
+final class PlanStatus {
+
+ public const ACTIVE = 'active';
+ public const ARCHIVED = 'archived';
+
+ /**
+ * The engine's default plan statuses (the registry seed).
+ *
+ * @return array<int, string>
+ */
+ public static function get_defaults(): array {
+ return array(
+ self::ACTIVE,
+ self::ARCHIVED,
+ );
+ }
+
+ /**
+ * Every registered plan status: the engine defaults, then extension
+ * registrations.
+ *
+ * @return array<int, string>
+ */
+ public static function get_all(): array {
+ return StatusRegistry::get_all( StatusRegistry::KIND_PLAN );
+ }
+
+ /**
+ * Whether `$status` is a registered plan status (an engine default or an
+ * extension registration). Write paths accept only registered statuses.
+ *
+ * @param string $status Status to check.
+ */
+ public static function is_registered( string $status ): bool {
+ return StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, $status );
+ }
+
+ /**
+ * Whether `$status` is a well-formed status slug (lowercase letters and digits in
+ * words joined by single hyphens, at most 20 characters), registered or not.
+ *
+ * @param string $status Status to check.
+ */
+ public static function is_valid( string $status ): bool {
+ return StatusRegistry::is_valid_slug( $status );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
index 6ef258dac51..5d0f8c1e92e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
@@ -1,10 +1,10 @@
<?php
/**
- * StatusRegistry - the set of registered contract and cycle statuses.
+ * StatusRegistry - the set of registered contract, cycle and plan statuses.
*
* Statuses are opaque engine data: the engine ships a default set per kind
- * ({@see ContractStatus::get_defaults()}, {@see CycleStatus::get_defaults()}) and
- * extensions may register more. The registry holds slugs only - no labels, no
+ * ({@see ContractStatus::get_defaults()}, {@see CycleStatus::get_defaults()},
+ * {@see PlanStatus::get_defaults()}) and extensions may register more. The registry holds slugs only - no labels, no
* transitions, no meaning - and is global (not per owner). Registration is the
* write-path allowlist: entity setters and the cycle status write refuse a slug
* that is not registered. Stored values outside the registry (for example one
@@ -39,6 +39,11 @@ final class StatusRegistry {
*/
public const KIND_CYCLE = 'cycle';
+ /**
+ * Plan status kind.
+ */
+ public const KIND_PLAN = 'plan';
+
/**
* Longest accepted slug (the status columns are `varchar(20)`).
*/
@@ -67,7 +72,7 @@ final class StatusRegistry {
* Idempotent: registering a default or an already-registered slug changes
* nothing.
*
- * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+ * @param string $kind One of {@see self::KIND_CONTRACT}, {@see self::KIND_CYCLE} or {@see self::KIND_PLAN}.
* @param string $slug Status slug; must satisfy {@see self::is_valid_slug()}.
* @throws InvalidArgumentException When the kind is unknown or the slug is malformed.
*/
@@ -95,7 +100,7 @@ final class StatusRegistry {
* Whether `$slug` is a registered status (a default or an extension
* registration) for `$kind`.
*
- * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+ * @param string $kind One of {@see self::KIND_CONTRACT}, {@see self::KIND_CYCLE} or {@see self::KIND_PLAN}.
* @param string $slug Status slug.
* @throws InvalidArgumentException When the kind is unknown.
*/
@@ -107,14 +112,24 @@ final class StatusRegistry {
* Every registered status for `$kind`: the engine defaults first, then
* extension registrations in registration order.
*
- * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+ * @param string $kind One of {@see self::KIND_CONTRACT}, {@see self::KIND_CYCLE} or {@see self::KIND_PLAN}.
* @return array<int, string>
* @throws InvalidArgumentException When the kind is unknown.
*/
public static function get_all( string $kind ): array {
self::assert_known_kind( $kind );
- $defaults = self::KIND_CONTRACT === $kind ? ContractStatus::get_defaults() : CycleStatus::get_defaults();
+ switch ( $kind ) {
+ case self::KIND_CONTRACT:
+ $defaults = ContractStatus::get_defaults();
+ break;
+ case self::KIND_CYCLE:
+ $defaults = CycleStatus::get_defaults();
+ break;
+ default:
+ $defaults = PlanStatus::get_defaults();
+ break;
+ }
return array_merge( $defaults, self::$registered[ $kind ] ?? array() );
}
@@ -146,7 +161,7 @@ final class StatusRegistry {
* @throws InvalidArgumentException When the kind is unknown.
*/
private static function assert_known_kind( string $kind ): void {
- if ( self::KIND_CONTRACT !== $kind && self::KIND_CYCLE !== $kind ) {
+ if ( ! in_array( $kind, array( self::KIND_CONTRACT, self::KIND_CYCLE, self::KIND_PLAN ), true ) ) {
throw new InvalidArgumentException(
sprintf( 'StatusRegistry: unknown status kind "%s".', $kind )
);
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
index 574d60974a4..fc2ad32cfb1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
@@ -116,6 +116,18 @@ final class Coercion {
return $result;
}
+ /**
+ * Coerce a value to a string-keyed array, or null when it is not an array. Only the
+ * declared type changes: PHP keeps an integer-like key as an int, so a list stays a list.
+ *
+ * @param mixed $value The raw value.
+ * @return array<string, mixed>|null
+ * @internal Engine implementation detail. Not part of the supported extension API.
+ */
+ public static function coerce_nullable_string_keyed( $value ): ?array {
+ return is_array( $value ) ? self::coerce_string_keyed( $value ) : null;
+ }
+
/**
* Coerce a value to a list of string-keyed rows. A non-array yields an empty
* list; non-array rows are skipped.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index fe7df76f64c..7fa2ee8e14b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -1,8 +1,16 @@
<?php
/**
- * BillingPolicy - typed value object for a plan's billing cadence and trial.
+ * BillingPolicy - an optional parser for a billing cadence and trial payload.
*
- * Mirrors the `billing_policy` JSON column shape. Shape:
+ * An extension may use {@see self::from_array()} to parse its plan billing arrays. The
+ * engine stores plan policies opaquely and does not construct this on plan writes. It
+ * does read one payload with it: renewal and reactivation parse a contract's plan
+ * snapshot `billing_policy`, and the live plan's `billing_policy` when the contract has
+ * no usable snapshot policy, both through {@see self::from_array()}, so a plan whose
+ * contracts the engine renews must store this shape there. A policy always has a
+ * usable cadence: construction refuses an unknown period or a non-positive interval.
+ * The array
+ * shape it parses:
* {
* period: 'day' | 'week' | 'month' | 'year',
* interval: int,
@@ -79,8 +87,15 @@ final class BillingPolicy {
* @param int|null $min_cycles Minimum cycles before cancellation is allowed.
* @param int|null $max_cycles Total cycles before the contract ends.
* @param array{length: int, unit: string}|null $trial_duration Native trial; null if none.
+ * @throws DomainException If the period is unknown, the interval is not positive, or the cycle bounds are invalid.
*/
public function __construct( string $period, int $interval, ?int $min_cycles, ?int $max_cycles, ?array $trial_duration ) {
+ if ( $interval <= 0 ) {
+ throw new DomainException(
+ sprintf( 'BillingPolicy: interval must be positive, got %d.', $interval )
+ );
+ }
+ $this->normalize_unit( $period, 'period' );
$this->validate_min_max_cycles( $min_cycles, $max_cycles );
$this->period = $period;
@@ -93,7 +108,8 @@ final class BillingPolicy {
/**
* Hydrate from the JSON-decoded `billing_policy` column shape.
*
- * Missing nullable keys default to null. `period` and `interval` are required.
+ * Missing nullable keys default to null. `period` and `interval` are required, and
+ * must form a usable cadence (see the constructor).
*
* @param array<string, mixed> $data Decoded billing_policy row.
* @throws DomainException If the data is not valid.
@@ -176,19 +192,11 @@ final class BillingPolicy {
*
* @param DateTimeImmutable $anchor The moment the next cycle is computed from.
* @return DateTimeImmutable The next renewal moment in UTC.
- * @throws DomainException If `period` is unknown or `interval` is not positive.
*/
public function compute_next_renewal_from( DateTimeImmutable $anchor ): DateTimeImmutable {
- if ( $this->interval <= 0 ) {
- throw new DomainException(
- sprintf( 'BillingPolicy::compute_next_renewal_from(): interval must be positive, got %d.', $this->interval )
- );
- }
-
- $unit = $this->normalize_unit( $this->period, 'period' );
- $utc = $anchor->setTimezone( new DateTimeZone( 'UTC' ) );
+ $utc = $anchor->setTimezone( new DateTimeZone( 'UTC' ) );
- return $utc->modify( sprintf( '+%d %s', $this->interval, $unit ) );
+ return $utc->modify( sprintf( '+%d %s', $this->interval, $this->period ) );
}
/**
@@ -200,7 +208,7 @@ final class BillingPolicy {
*
* @param DateTimeImmutable $contract_start Moment the contract was created.
* @return DateTimeImmutable The first renewal moment in UTC.
- * @throws DomainException If trial length is not positive or trial unit is unknown.
+ * @throws DomainException If the trial length is not positive or the trial unit is unknown.
*/
public function compute_first_renewal_from( DateTimeImmutable $contract_start ): DateTimeImmutable {
if ( null === $this->trial_duration ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
index c7099062158..2fed06c187a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
@@ -1,10 +1,12 @@
<?php
/**
- * DeliveryPolicy - typed value object for a plan's delivery anchors, cutoff,
- * and intent.
+ * DeliveryPolicy - an optional parser for a delivery anchors, cutoff, and
+ * intent payload.
*
- * Mirrors the `delivery_policy` JSON column shape, deliberately thin for now.
- * Shape:
+ * An opt-in parser for extensions: an extension may use {@see self::from_array()} to
+ * parse its plan delivery arrays. No engine code consumes it yet; the engine stores
+ * plan policies opaquely and does not construct this on plan reads or writes.
+ * Deliberately thin for now. The array shape it parses:
* {
* anchors: [{ type: 'MONTHDAY', day: int }, { type: 'YEARDAY', day: int, month: int }, ...],
* cutoff: ?mixed,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index 020b88710ef..f02b1e23ac2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -101,29 +101,21 @@ final class PlanSnapshot {
}
/**
- * The frozen billing cadence, reconstructed from the snapshot payload.
+ * The frozen billing cadence captured at signup, parsed with the engine's renewal
+ * rule ({@see BillingPolicy::from_array()}), so it holds after
+ * the source plan is edited or deleted. Null when the payload carries no billing
+ * policy array; throws when one is present but unusable, so a caller can log why
+ * before falling back.
*
- * Sourced from the `billing_policy` entry captured at signup, NOT the live plan -
- * so a consumer reads the cadence a contract is billed under straight off the
- * snapshot, even after the plan it came from is edited or deleted, with no live
- * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository}
- * join. Returns null when the payload carries no (or an unreadable) billing policy,
- * so a caller degrades to "no cadence" rather than fataling.
+ * @throws DomainException If the stored policy does not parse or has no usable cadence.
*/
- public function get_billing_policy(): ?BillingPolicy {
+ public function read_billing_policy(): ?BillingPolicy {
$policy = $this->data['billing_policy'] ?? null;
if ( ! is_array( $policy ) ) {
return null;
}
- try {
- return BillingPolicy::from_array( self::string_keyed( $policy ) );
- } catch ( DomainException $e ) {
- // A structurally-invalid stored policy degrades to "no cadence" rather than
- // fataling the read; snapshots this engine writes always carry a valid policy.
- unset( $e );
- return null;
- }
+ return BillingPolicy::from_array( Coercion::coerce_string_keyed( $policy ) );
}
/**
@@ -136,7 +128,7 @@ final class PlanSnapshot {
public function get_pricing_policy(): ?array {
$policy = $this->data['pricing_policy'] ?? null;
- return is_array( $policy ) ? self::string_keyed( $policy ) : null;
+ return is_array( $policy ) ? Coercion::coerce_string_keyed( $policy ) : null;
}
/**
@@ -157,21 +149,4 @@ final class PlanSnapshot {
public function to_payload(): array {
return $this->data;
}
-
- /**
- * Re-key a nested payload array as string-keyed for the typed value-object factory.
- * A no-op at runtime (decoded JSON object keys are already strings); it recovers the
- * string-keyed type that erases to `array<int|string, mixed>`.
- *
- * @param array<int|string, mixed> $value Nested payload array.
- * @return array<string, mixed>
- */
- private static function string_keyed( array $value ): array {
- $out = array();
- foreach ( $value as $key => $item ) {
- $out[ (string) $key ] = $item;
- }
-
- return $out;
- }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
index 5f9bba82130..3200a85e860 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -30,7 +30,6 @@ use DomainException;
use RuntimeException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -224,16 +223,30 @@ final class Reactivation {
* The billing policy the forward roll steps by: the contract's own frozen plan
* terms first (the snapshot is what the contract actually bills under - the same
* source the renewal money-path resolves), falling back to the live selling plan
- * for a contract with no snapshot, and null when neither resolves.
+ * (parsing its billing payload) when the contract has no snapshot or its snapshot
+ * policy does not parse or has no usable cadence (logged), and null when neither
+ * resolves (a live plan with a null or unusable billing payload is logged too). Both sources are read with the renewal rule
+ * ({@see BillingPolicy::from_array()}), so the forward roll never
+ * throws on a stored payload.
*
* @param Contract $contract The contract.
*/
private function billing_policy( Contract $contract ): ?BillingPolicy {
$snapshot = $contract->get_plan_snapshot();
if ( null !== $snapshot ) {
- $policy = $snapshot->get_billing_policy();
- if ( $policy instanceof BillingPolicy ) {
- return $policy;
+ try {
+ $policy = $snapshot->read_billing_policy();
+ if ( null !== $policy ) {
+ return $policy;
+ }
+ } catch ( DomainException $e ) {
+ wc_get_logger()->warning(
+ sprintf( 'Reactivation: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ )
+ );
}
}
@@ -243,7 +256,37 @@ final class Reactivation {
}
$plan = $this->plans->find( $plan_id );
+ if ( null === $plan ) {
+ return null;
+ }
- return $plan instanceof Plan ? $plan->get_billing_policy() : null;
+ $billing = $plan->get_billing_policy();
+ if ( null === $billing ) {
+ wc_get_logger()->warning(
+ sprintf( 'Reactivation: contract %d has a live plan %d with no billing policy; a past-due next payment is floored at now.', (int) $contract->get_id(), (int) $plan_id ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ 'plan_id' => (int) $plan_id,
+ )
+ );
+
+ return null;
+ }
+
+ try {
+ return BillingPolicy::from_array( $billing );
+ } catch ( DomainException $e ) {
+ wc_get_logger()->warning(
+ sprintf( 'Reactivation: contract %d has an unreadable live plan billing policy; a past-due next payment is floored at now. %s', (int) $contract->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ 'plan_id' => (int) $plan_id,
+ )
+ );
+
+ return null;
+ }
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
index fc5960553f5..14c465279ca 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -39,7 +39,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
@@ -388,9 +387,12 @@ final class RenewalEngine {
/**
* Resolve the billing policy the next cycle bills under, from the contract's own plan
* snapshot - the live source of truth, so a contract updated since an earlier cycle bills
- * on its current terms. Falls back to the contract's selling plan when it carries no
- * snapshot, and returns null when neither resolves (a deleted plan) so the caller skips
- * gracefully rather than mis-billing.
+ * on its current terms. Falls back to parsing the live selling plan's billing payload when
+ * the contract carries no snapshot, or one whose billing policy is absent or unusable (that
+ * case is logged), and returns null when neither resolves (a deleted plan, or a live
+ * billing payload that is null, does not parse or has no usable cadence; the payload
+ * cases are logged) so the caller parks the contract rather than mis-billing or
+ * retrying every tick.
*
* @param Contract $contract The contract being renewed.
* @return BillingPolicy|null The billing policy, or null when unresolvable.
@@ -398,21 +400,21 @@ final class RenewalEngine {
private function resolve_billing_policy( Contract $contract ): ?BillingPolicy {
$snapshot = $this->resolve_plan_snapshot( $contract );
if ( $snapshot instanceof PlanSnapshot ) {
- $payload = $snapshot->to_array();
- if ( isset( $payload['billing_policy'] ) && is_array( $payload['billing_policy'] ) ) {
- try {
- return BillingPolicy::from_array( self::string_keyed( $payload['billing_policy'] ) );
- } catch ( \DomainException $e ) {
- // A corrupt stored policy must not crash the scheduled run; fall through to the
- // live plan below so the renewal can still resolve on current terms.
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
+ try {
+ $policy = $snapshot->read_billing_policy();
+ if ( null !== $policy ) {
+ return $policy;
}
+ } catch ( \DomainException $e ) {
+ // A corrupt stored policy must not crash the scheduled run; fall through to the
+ // live plan below so the renewal can still resolve on current terms.
+ wc_get_logger()->warning(
+ sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ )
+ );
}
}
@@ -422,7 +424,38 @@ final class RenewalEngine {
}
$plan = $this->plans->find( $plan_id );
- return $plan instanceof Plan ? $plan->get_billing_policy() : null;
+ if ( null === $plan ) {
+ return null;
+ }
+
+ $billing = $plan->get_billing_policy();
+ if ( null === $billing ) {
+ wc_get_logger()->warning(
+ sprintf( 'RenewalEngine: contract %d has a live plan %d with no billing policy; the renewal cannot be processed.', (int) $contract->get_id(), (int) $plan_id ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ 'plan_id' => (int) $plan_id,
+ )
+ );
+
+ return null;
+ }
+
+ try {
+ return BillingPolicy::from_array( $billing );
+ } catch ( \DomainException $e ) {
+ wc_get_logger()->warning(
+ sprintf( 'RenewalEngine: contract %d has an unreadable live plan billing policy; the renewal cannot be processed. %s', (int) $contract->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ 'plan_id' => (int) $plan_id,
+ )
+ );
+
+ return null;
+ }
}
/**
@@ -983,20 +1016,6 @@ final class RenewalEngine {
return is_numeric( $value ) ? (int) $value : 0;
}
- /**
- * Coerce a decoded array to a string-keyed array for the typed value-object factories.
- *
- * @param array<mixed, mixed> $value The decoded array.
- * @return array<string, mixed>
- */
- private static function string_keyed( array $value ): array {
- $out = array();
- foreach ( $value as $key => $item ) {
- $out[ (string) $key ] = $item;
- }
- return $out;
- }
-
/**
* Attempt the gateway charge for `$renewal_order`.
*
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 dead97a62cb..196f44d6367 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -1,6 +1,7 @@
<?php
/**
- * PlanRepository - persistence for {@see Plan} entities.
+ * PlanRepository - persistence for {@see Plan} entities. The three policy columns are
+ * stored as the opaque JSON payloads the entity carries; null stays SQL NULL.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
*/
@@ -44,86 +45,63 @@ final class PlanRepository {
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.
- *
- * `merchant_code` uniqueness is DB-enforced per extension (composite UNIQUE
- * with `extension_slug`, NULLs distinct): a duplicate code within one
- * extension fails the insert and surfaces as the RuntimeException.
+ * Insert a new plan and stamp its id and stored dates back onto the entity.
*
* @param Plan $plan Plan to insert.
* @return int The new plan id.
- * @throws \RuntimeException If the insert fails, including on a duplicate merchant_code.
+ * @throws \RuntimeException If the insert fails.
*/
public function insert( Plan $plan ): int {
global $wpdb;
$now = gmdate( 'Y-m-d H:i:s' );
- $data = $plan->to_storage();
+ $data = $this->get_row_data( $plan );
+
+ $data['date_created_gmt'] = $now;
+ $data['date_updated_gmt'] = $now;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $inserted = $wpdb->insert(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ),
- array(
- 'name' => $data['name'],
- 'description' => $data['description'],
- 'billing_policy' => wp_json_encode( $data['billing_policy'] ),
- 'delivery_policy' => null !== $data['delivery_policy'] ? wp_json_encode( $data['delivery_policy'] ) : null,
- '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'],
- 'merchant_code' => $data['merchant_code'],
- 'extension_slug' => $data['extension_slug'],
- 'date_created_gmt' => $now,
- 'date_updated_gmt' => $now,
- )
- );
+ $inserted = $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), $data );
if ( false === $inserted ) {
- throw new \RuntimeException( 'Failed to insert plan.' );
+ throw new \RuntimeException( sprintf( 'Failed to insert plan: %s', esc_html( $wpdb->last_error ) ) );
}
$id = (int) $wpdb->insert_id;
$plan->set_id( $id );
+ $plan->set_date_created_gmt( $now );
+ $plan->set_date_updated_gmt( $now );
return $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.
+ * Fetch a plan by id, in any status, of any extension or (when given) only of the given extension.
*
* @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.
+ * @param string|null $extension_slug Owning extension slug to scope the read to; null reads any extension.
+ * @return Plan|null Hydrated plan, or null if not found (also when it belongs to another extension).
*/
public function find( int $id, ?string $extension_slug = null ): ?Plan {
global $wpdb;
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
- $extension_clause = '';
- $params = array( $id );
- if ( null !== $extension_slug && 'any' !== $extension_slug ) {
- $extension_clause = ' AND extension_slug = %s';
- $params[] = $extension_slug;
+ if ( null === $extension_slug ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id );
+ } else {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND extension_slug = %s", $id, $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
- );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+ $row = $wpdb->get_row( $sql, ARRAY_A );
if ( null === $row ) {
return null;
@@ -136,12 +114,14 @@ final class PlanRepository {
* Query plans.
*
* Supported args: limit, offset, search, status, extension_slugs, ids,
- * orderby, order. `extension_slugs` filters by owning extension: a list
- * of slugs (a single-slug list unfolds to an equality match) or
- * `array( 'any' )` to skip the scope. `ids` filters to plans whose id is
- * in the given int list; it composes with the other filters and is
- * honored by count(). Results default to manual order, oldest id as a
- * stable tiebreaker.
+ * orderby, order. `status` is a slug or a list of slugs (an empty list or a
+ * non-string entry matches nothing). `extension_slugs` filters by owning
+ * extension: a list of slugs (a single-slug list unfolds to an equality
+ * match) or `array( 'any' )` to skip the scope. `ids` filters to plans whose
+ * id is in the given int list; it composes with the other filters and is
+ * honored by count(). `search` matches the name. `orderby` is one of `id`
+ * (default), `name`, `date_created_gmt`, `date_updated_gmt`, with the id
+ * ascending as a stable tiebreaker.
*
* @param array<string, mixed> $args Query args.
* @return array<int, Plan>
@@ -173,7 +153,7 @@ final class PlanRepository {
if ( ! is_array( $row ) ) {
continue;
}
- $plans[] = $this->hydrate_row( self::string_keyed_array( $row ) );
+ $plans[] = $this->hydrate_row( Coercion::coerce_string_keyed( $row ) );
}
return $plans;
@@ -209,16 +189,21 @@ final class PlanRepository {
}
/**
- * Persist changes to an existing plan.
+ * Write only the given columns of an existing plan's row (plus its update time, stamped
+ * back onto the entity), so columns a concurrent writer changed in between keep its
+ * values. The write is scoped to the plan's extension: it matches the row by id and the
+ * entity's extension slug, so a row of another extension is never written. Plan meta is
+ * never touched. Existence (by id and extension slug) is checked only when the write changes
+ * nothing. Opens no transaction: a caller's transaction covers the write.
*
- * `merchant_code` is immutable post-create and intentionally not written here,
- * same as `id`.
- *
- * @param Plan $plan Plan to update. Must have an id.
- * @return bool True on success.
- * @throws \RuntimeException If the plan has no id.
+ * @param Plan $plan Plan to read the values from. Must have an id and an extension slug.
+ * @param array<int, string> $fields Columns to write: `name`, `status`, `billing_policy`,
+ * `pricing_policy`, `delivery_policy`.
+ * @return bool False when no row of the plan's extension has its id (nothing is written).
+ * @throws \InvalidArgumentException If a field is not a writable column.
+ * @throws \RuntimeException If the plan has no id or no extension slug, or the update fails.
*/
- public function update( Plan $plan ): bool {
+ public function update_fields( Plan $plan, array $fields ): bool {
global $wpdb;
$id = $plan->get_id();
@@ -226,37 +211,82 @@ final class PlanRepository {
throw new \RuntimeException( 'Cannot update a plan that has no id.' );
}
- $data = $plan->to_storage();
+ $extension_slug = $plan->get_extension_slug();
+ if ( null === $extension_slug || '' === $extension_slug ) {
+ throw new \RuntimeException( 'Cannot update a plan that has no extension slug.' );
+ }
+
+ $row = $this->get_row_data( $plan );
+ $columns = array();
+ foreach ( $fields as $field ) {
+ if ( 'extension_slug' === $field || ! array_key_exists( $field, $row ) ) {
+ throw new \InvalidArgumentException( esc_html( sprintf( 'Cannot update plan field "%s".', $field ) ) );
+ }
+ $columns[ $field ] = $row[ $field ];
+ }
+
+ $now = gmdate( 'Y-m-d H:i:s' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->update(
SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ),
+ array_merge( $columns, array( 'date_updated_gmt' => $now ) ),
array(
- 'name' => $data['name'],
- 'description' => $data['description'],
- 'billing_policy' => wp_json_encode( $data['billing_policy'] ),
- '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' ),
- ),
- array( 'id' => $id )
+ 'id' => $id,
+ 'extension_slug' => $extension_slug,
+ )
);
- return false !== $updated;
+ if ( false === $updated ) {
+ throw new \RuntimeException( sprintf( 'Failed to update plan %d: %s', (int) $id, esc_html( $wpdb->last_error ) ) );
+ }
+
+ // Zero changed rows: no row of this extension, or identical values written within the same second.
+ if ( 0 === $updated && ! $this->exists( $id, $extension_slug ) ) {
+ return false;
+ }
+
+ $plan->set_date_updated_gmt( $now );
+
+ return true;
+ }
+
+ /**
+ * Whether a plan row exists, of any extension or (when given) of the given extension.
+ *
+ * @param int $id Plan id.
+ * @param string|null $extension_slug Owning extension slug to scope the check to; null checks any extension.
+ */
+ public function exists( int $id, ?string $extension_slug = null ): bool {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+
+ if ( null === $extension_slug ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d", $id );
+ } else {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d AND extension_slug = %s", $id, $extension_slug );
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
+ $found = $wpdb->get_var( $sql );
+
+ return null !== $found;
}
/**
- * Delete a plan by id and (optionally) extension slug.
+ * Delete a plan and its meta rows by id and (optionally) extension slug.
* Most usages from applications should specify the extension slug
* to guard against cross-application operations.
*
+ * A failed delete throws, so a caller's transaction can roll back.
+ *
* @param int $id Plan id.
* @param string|null $extension_slug Extension slug for the plan.
* @return bool True when a row was removed.
+ * @throws \RuntimeException If the plan row or its meta rows fail to delete.
*/
public function delete( int $id, ?string $extension_slug = null ): bool {
global $wpdb;
@@ -270,75 +300,175 @@ final class PlanRepository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), $where );
- return (bool) $deleted;
+ if ( false === $deleted ) {
+ throw new \RuntimeException( sprintf( 'Failed to delete plan %d: %s', (int) $id, esc_html( $wpdb->last_error ) ) );
+ }
+
+ if ( 0 === $deleted ) {
+ return false;
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $deleted_meta = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ), array( 'plan_id' => $id ) );
+
+ if ( false === $deleted_meta ) {
+ throw new \RuntimeException( sprintf( 'Failed to delete meta rows for plan %d: %s', (int) $id, esc_html( $wpdb->last_error ) ) );
+ }
+
+ return true;
}
/**
- * Persist manual sort-order values for plans in one extension.
+ * Add a meta row for a plan, like `add_post_meta()`.
*
- * @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.
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @param mixed $value Meta value; serialized when not scalar.
+ * @param bool $unique When true, add nothing if the key already exists. Advisory:
+ * checked before the insert with no unique index.
+ * @return int|null The new meta row id, or null when `$unique` and the key exists.
+ * @throws \InvalidArgumentException If `$key` is empty.
+ * @throws \RuntimeException If the insert fails.
*/
- public function reorder( string $extension_slug, array $sort_order_by_id ): bool {
+ public function add_meta( int $plan_id, string $key, $value, bool $unique = false ): ?int {
global $wpdb;
- if ( ! self::is_valid_extension_slug( $extension_slug ) ) {
- return false;
+ if ( '' === $key ) {
+ throw new \InvalidArgumentException( 'Plan meta key must not be empty.' );
+ }
+
+ if ( $unique && array() !== $this->find_meta_values( $plan_id, $key ) ) {
+ return null;
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $inserted = $wpdb->insert(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ),
+ array(
+ 'plan_id' => $plan_id,
+ 'meta_key' => $key,
+ 'meta_value' => maybe_serialize( $value ),
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( sprintf( 'Failed to add plan meta "%s" for plan %d: %s', esc_html( $key ), (int) $plan_id, esc_html( $wpdb->last_error ) ) );
}
- if ( array() === $sort_order_by_id ) {
+ return (int) $wpdb->insert_id;
+ }
+
+ /**
+ * Update a plan's meta rows for `$key`, like `update_post_meta()`: adds a row when
+ * the key is absent, else rewrites every row for the key, or only the rows holding
+ * `$prev_value`. The absent-key check runs before the write with no unique index.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @param mixed $value New value; serialized when not scalar.
+ * @param mixed $prev_value Only update rows holding this value; null updates all rows for the key.
+ * Any other value ('' and false included) matches literally.
+ * @return bool True when a row was added or at least one row changed.
+ * @throws \InvalidArgumentException If `$key` is empty.
+ * @throws \RuntimeException If a write fails.
+ */
+ public function update_meta( int $plan_id, string $key, $value, $prev_value = null ): bool {
+ global $wpdb;
+
+ if ( '' === $key ) {
+ throw new \InvalidArgumentException( 'Plan meta key must not be empty.' );
+ }
+
+ if ( array() === $this->find_meta_values( $plan_id, $key ) ) {
+ $this->add_meta( $plan_id, $key, $value );
return true;
}
- $ok = true;
- $now = gmdate( 'Y-m-d H:i:s' );
+ $where = array(
+ 'plan_id' => $plan_id,
+ 'meta_key' => $key,
+ );
+ if ( null !== $prev_value ) {
+ $where['meta_value'] = maybe_serialize( $prev_value );
+ }
- $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;
- }
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $updated = $wpdb->update(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ),
+ array( 'meta_value' => maybe_serialize( $value ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $where
+ );
+
+ if ( false === $updated ) {
+ throw new \RuntimeException( sprintf( 'Failed to update plan meta "%s" for plan %d: %s', esc_html( $key ), (int) $plan_id, esc_html( $wpdb->last_error ) ) );
}
- $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
- $params = array_merge( array( $extension_slug ), $ids );
+ return $updated > 0;
+ }
- // 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 Coercion::coerce_int( $matched_id );
- },
- $matched_ids
- )
- )
- : array();
- if ( count( $matched_ids ) !== count( $ids ) ) {
- return false;
+ /**
+ * Delete a plan's meta rows for `$key`, like `delete_post_meta()`.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @param mixed $value Only delete rows holding this value; null deletes every row for the key.
+ * Any other value ('' and false included) matches literally.
+ * @return bool True when at least one row was deleted.
+ * @throws \InvalidArgumentException If `$key` is empty.
+ * @throws \RuntimeException If the delete fails.
+ */
+ public function delete_meta( int $plan_id, string $key, $value = null ): bool {
+ global $wpdb;
+
+ if ( '' === $key ) {
+ throw new \InvalidArgumentException( 'Plan meta key must not be empty.' );
}
- 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,
- )
- );
+ $where = array(
+ 'plan_id' => $plan_id,
+ 'meta_key' => $key,
+ );
+ if ( null !== $value ) {
+ $where['meta_value'] = maybe_serialize( $value );
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ), $where );
- $ok = $ok && false !== $updated;
+ if ( false === $deleted ) {
+ throw new \RuntimeException( sprintf( 'Failed to delete plan meta "%s" for plan %d: %s', esc_html( $key ), (int) $plan_id, esc_html( $wpdb->last_error ) ) );
}
- return $ok;
+ return $deleted > 0;
+ }
+
+ /**
+ * Read plan meta (WordPress `get_post_meta()` semantics), values unserialized,
+ * oldest row first.
+ *
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key; empty for every key.
+ * @param bool $single With a key: return the first value only.
+ * @return mixed Empty key: `array<string, array<int, mixed>>` of all keys. Key + `$single`:
+ * the first value, or '' when absent. Key only: the list of values (`[]` when absent).
+ */
+ public function get_meta( int $plan_id, string $key = '', bool $single = false ) {
+ if ( '' === $key ) {
+ $all = array();
+ foreach ( $this->find_meta_rows( $plan_id, null ) as $row ) {
+ $all[ $row['meta_key'] ][] = maybe_unserialize( $row['meta_value'] );
+ }
+
+ return $all;
+ }
+
+ $values = $this->find_meta_values( $plan_id, $key );
+
+ if ( $single ) {
+ return array() === $values ? '' : $values[0];
+ }
+
+ return $values;
}
/**
@@ -353,10 +483,26 @@ final class PlanRepository {
$clauses = array();
$params = array();
- $status = Coercion::coerce_string( $args['status'] ?? null );
- if ( '' !== $status ) {
- $clauses[] = 'status = %s';
- $params[] = $status;
+ if ( array_key_exists( 'status', $args ) && null !== $args['status'] ) {
+ $statuses = is_array( $args['status'] ) ? array_values( $args['status'] ) : array( $args['status'] );
+ $valid = array();
+ foreach ( $statuses as $status ) {
+ if ( ! is_string( $status ) || '' === $status ) {
+ $valid = array();
+ break;
+ }
+ $valid[ $status ] = $status;
+ }
+
+ if ( array() === $valid ) {
+ $clauses[] = self::MATCH_NOTHING;
+ } elseif ( 1 === count( $valid ) ) {
+ $clauses[] = 'status = %s';
+ $params[] = reset( $valid );
+ } else {
+ $clauses[] = 'status IN (' . implode( ',', array_fill( 0, count( $valid ), '%s' ) ) . ')';
+ $params = array_merge( $params, array_values( $valid ) );
+ }
}
if ( array_key_exists( 'extension_slugs', $args ) && null !== $args['extension_slugs'] ) {
@@ -428,8 +574,7 @@ final class PlanRepository {
$search = Coercion::coerce_string( $args['search'] ?? null );
if ( '' !== $search ) {
$like = '%' . $wpdb->esc_like( $search ) . '%';
- $clauses[] = '(name LIKE %s OR description LIKE %s)';
- $params[] = $like;
+ $clauses[] = 'name LIKE %s';
$params[] = $like;
}
@@ -453,13 +598,11 @@ final class PlanRepository {
*/
private function build_order_clause( array $args ): string {
$orderby_arg = Coercion::coerce_string( $args['orderby'] ?? null );
- $orderby = isset( self::ORDERBY_COLUMNS[ $orderby_arg ] )
- ? self::ORDERBY_COLUMNS[ $orderby_arg ]
- : 'sort_order';
+ $orderby = self::ORDERBY_COLUMNS[ $orderby_arg ] ?? 'id';
$order = 'desc' === strtolower( Coercion::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';
- if ( 'sort_order' === $orderby ) {
- return "ORDER BY sort_order {$order}, id ASC";
+ if ( 'id' === $orderby ) {
+ return "ORDER BY id {$order}";
}
return "ORDER BY {$orderby} {$order}, id ASC";
@@ -480,6 +623,22 @@ final class PlanRepository {
return true;
}
+ /**
+ * The writable plan columns for `$plan`, policies JSON-encoded (null stays null).
+ *
+ * @param Plan $plan Plan.
+ * @return array<string, mixed>
+ */
+ private function get_row_data( Plan $plan ): array {
+ $data = $plan->to_storage();
+
+ foreach ( self::JSON_COLUMNS as $column ) {
+ $data[ $column ] = null !== $data[ $column ] ? wp_json_encode( $data[ $column ] ) : null;
+ }
+
+ return $data;
+ }
+
/**
* Hydrate a database row into a plan.
*
@@ -496,9 +655,8 @@ final class PlanRepository {
/**
* Decode a JSON column into an array.
*
- * A SQL NULL column stays null so nullable policy columns
- * (delivery_policy, pricing_policy) round-trip back to null rather than to
- * an empty value object. A present-but-empty value decodes to an array.
+ * A SQL NULL column stays null so the nullable policy columns round-trip
+ * back to null. A present-but-empty value decodes to an empty array.
*
* @param mixed $value Raw column value.
* @return array<mixed>|null
@@ -518,19 +676,53 @@ final class PlanRepository {
}
/**
- * Normalize a database row to string keys.
+ * Unserialized values stored under `$key` for a plan, oldest first.
*
- * @param array<array-key, mixed> $row Raw row.
- * @return array<string, mixed>
+ * @param int $plan_id Plan id.
+ * @param string $key Meta key.
+ * @return array<int, mixed>
+ */
+ private function find_meta_values( int $plan_id, string $key ): array {
+ $values = array();
+ foreach ( $this->find_meta_rows( $plan_id, $key ) as $row ) {
+ $values[] = maybe_unserialize( $row['meta_value'] );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Raw meta rows for a plan, optionally for one key, by id ascending.
+ *
+ * @param int $plan_id Plan id.
+ * @param string|null $key Meta key, or null for every key.
+ * @return array<int, array{meta_key: string, meta_value: string}>
*/
- private static function string_keyed_array( array $row ): array {
- $data = array();
- foreach ( $row as $key => $value ) {
- if ( is_string( $key ) ) {
- $data[ $key ] = $value;
+ private function find_meta_rows( int $plan_id, ?string $key ): array {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META );
+
+ if ( null === $key ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE plan_id = %d ORDER BY id ASC", $plan_id ), ARRAY_A );
+ } else {
+ // The engine's own plan-meta columns, not post/order meta; the
+ // slow-meta-query heuristic does not apply.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE plan_id = %d AND meta_key = %s ORDER BY id ASC", $plan_id, $key ), ARRAY_A );
+ }
+
+ $result = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ if ( is_array( $row ) ) {
+ $result[] = array(
+ 'meta_key' => Coercion::coerce_string( $row['meta_key'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => Coercion::coerce_string( $row['meta_value'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ );
}
}
- return $data;
+ return $result;
}
}
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 c2bf1c54a99..ad3450d39c1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -46,13 +46,17 @@ final class SchemaInstaller {
* 2.5.0 - contracts `customer_id`, `currency`, `selling_plan_id`, `start_gmt` nullable;
* contract_meta indexes `meta_key_value` and `contract_meta_key_value` (HPOS
* shape) replace `contract_key`; pre-freeze, recreate the tables.
+ * 2.6.0 - plans drop `description`, `category`, `sort_order`, `merchant_code`,
+ * `inventory_policy` and their indexes; `billing_policy` nullable; an
+ * `extension_status (extension_slug, status)` index; new selling_plan_meta
+ * table (HPOS-style indexes); pre-freeze, recreate the tables.
*
* 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.
*/
- private const VERSION = '2.5.0';
+ private const VERSION = '2.6.0';
/**
* Option key tracking the installed schema version.
@@ -63,6 +67,7 @@ final class SchemaInstaller {
* Logical table identifiers - keys map to unprefixed table names.
*/
public const TABLE_PLANS = 'plans';
+ public const TABLE_PLAN_META = 'plan_meta';
public const TABLE_CONTRACTS = 'contracts';
public const TABLE_CONTRACT_ITEMS = 'contract_items';
public const TABLE_CONTRACT_ADDRESSES = 'contract_addresses';
@@ -171,6 +176,7 @@ final class SchemaInstaller {
private static function get_table_names( string $prefix ): array {
return array(
self::TABLE_PLANS => $prefix . 'wc_selling_plans',
+ self::TABLE_PLAN_META => $prefix . 'wc_selling_plan_meta',
self::TABLE_CONTRACTS => $prefix . 'wc_subscription_contracts',
self::TABLE_CONTRACT_ITEMS => $prefix . 'wc_subscription_contract_items',
self::TABLE_CONTRACT_ADDRESSES => $prefix . 'wc_subscription_contract_addresses',
@@ -193,6 +199,7 @@ final class SchemaInstaller {
*/
private static function get_table_definitions( array $names, string $collate ): array {
$plans = $names[ self::TABLE_PLANS ];
+ $plan_meta = $names[ self::TABLE_PLAN_META ];
$contracts = $names[ self::TABLE_CONTRACTS ];
$contract_items = $names[ self::TABLE_CONTRACT_ITEMS ];
$contract_addresses = $names[ self::TABLE_CONTRACT_ADDRESSES ];
@@ -200,32 +207,35 @@ final class SchemaInstaller {
$cycles = $names[ self::TABLE_CYCLES ];
$snapshots = $names[ self::TABLE_SNAPSHOTS ];
- // `merchant_code` is DB-enforced-unique per extension (composite with
- // `extension_slug`) for idempotency on consumer-supplied codes - each consumer
- // owns its own code namespace; NULLs are treated as distinct, so consumers that
- // do not use merchant codes are unaffected. `extension_slug` records the creating
- // extension's registered slug. Nullable while owner identifier/registration
- // semantics are still open; tightened additively once decided.
+ // Mirrors the HPOS orders meta table indexes, including its meta_value prefix length.
+ $meta_value_index_length = max( min( absint( apply_filters( 'woocommerce_database_max_index_length', 191 ) ), 767 ) - 8 - 100 - 1, 20 );
+
+ // The three policies are opaque JSON payloads of the owning extension; the engine
+ // checks their shape only. `extension_slug` is the owner (nullable while owner
+ // registration semantics are still open). `extension_status` keys owner-scoped
+ // reads filtered by status.
$plans_sql = "CREATE TABLE {$plans} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
- description TEXT NULL,
- billing_policy JSON NOT NULL,
+ billing_policy JSON NULL,
delivery_policy JSON NULL,
- 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,
- merchant_code VARCHAR(64) NULL,
extension_slug VARCHAR(64) NULL,
date_created_gmt DATETIME NOT NULL,
date_updated_gmt DATETIME NOT NULL,
PRIMARY KEY (id),
- UNIQUE KEY extension_merchant_code (extension_slug, merchant_code),
- KEY category (category),
- KEY status_sort (status, sort_order, id),
- KEY extension_slug (extension_slug)
+ KEY extension_status (extension_slug, status)
+) {$collate};";
+
+ $plan_meta_sql = "CREATE TABLE {$plan_meta} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ plan_id BIGINT UNSIGNED NOT NULL,
+ meta_key VARCHAR(255) NOT NULL,
+ meta_value LONGTEXT NULL,
+ PRIMARY KEY (id),
+ KEY meta_key_value (meta_key(50), meta_value(20)),
+ KEY plan_meta_key_value (plan_id, meta_key(100), meta_value({$meta_value_index_length}))
) {$collate};";
// The contract row is the live source of truth: the totals and stamps are live
@@ -305,9 +315,6 @@ final class SchemaInstaller {
PRIMARY KEY (contract_id, address_type)
) {$collate};";
- // Mirrors the HPOS orders meta table indexes, including its meta_value prefix length.
- $meta_value_index_length = max( min( absint( apply_filters( 'woocommerce_database_max_index_length', 191 ) ), 767 ) - 8 - 100 - 1, 20 );
-
$contract_meta_sql = "CREATE TABLE {$contract_meta} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
contract_id BIGINT UNSIGNED NOT NULL,
@@ -378,6 +385,7 @@ final class SchemaInstaller {
return array(
$plans_sql,
+ $plan_meta_sql,
$contracts_sql,
$contract_items_sql,
$contract_addresses_sql,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
index ad8aa3a6cae..22b17374ad5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
@@ -1,6 +1,6 @@
<?php
/**
- * Argument validators shared by the public write facades.
+ * Argument validators shared by the public facades.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
*/
@@ -22,7 +22,7 @@ defined( 'ABSPATH' ) || exit;
/**
* Validate caller argument values and return them normalized.
*
- * @internal Engine implementation detail shared by the `Api\` write facades, not part of the public API.
+ * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
*/
final class ArgumentValidator {
@@ -60,7 +60,7 @@ final class ArgumentValidator {
* @throws InvalidArgumentException If the value is not null or a three-letter uppercase code.
*/
public static function validate_currency( $value ): ?string {
- if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}$/', $value ) ) ) {
+ if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}\z/', $value ) ) ) {
throw new InvalidArgumentException( '"currency" must be null or a three-letter uppercase ISO-4217 code.' );
}
@@ -82,6 +82,21 @@ final class ArgumentValidator {
return $value;
}
+ /**
+ * Validate and return a non-empty string.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not a non-empty string.
+ */
+ public static function validate_non_empty_string( string $key, $value ): string {
+ if ( ! is_string( $value ) || '' === $value ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
/**
* Validate and return a string, or null.
*
@@ -109,10 +124,7 @@ final class ArgumentValidator {
return null;
}
- if ( is_string( $value ) && 1 === preg_match( '/^[0-9]+$/', $value ) ) {
- $value = (int) $value;
- }
-
+ $value = self::cast_digit_string( $value );
if ( ! is_int( $value ) || $value <= 0 ) {
throw new InvalidArgumentException( sprintf( '"%s" must be null or a positive integer.', esc_html( $key ) ) );
}
@@ -120,6 +132,88 @@ final class ArgumentValidator {
return $value;
}
+ /**
+ * Validate and return a non-negative integer (a digit string is cast).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not a non-negative integer.
+ */
+ public static function validate_non_negative_int( string $key, $value ): int {
+ $value = self::cast_digit_string( $value );
+ if ( ! is_int( $value ) || $value < 0 ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a non-negative integer.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate a list of positive integer ids (digit strings are cast) and return it.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, int>
+ * @throws InvalidArgumentException If the value is not a list of positive integers.
+ */
+ public static function validate_id_list( string $key, $value ): array {
+ if ( ! self::is_list( $value ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a list of positive integers.', esc_html( $key ) ) );
+ }
+
+ $ids = array();
+ foreach ( $value as $id ) {
+ $id = self::cast_digit_string( $id );
+ if ( ! is_int( $id ) || $id <= 0 ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a list of positive integers.', esc_html( $key ) ) );
+ }
+ $ids[] = $id;
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Validate a non-empty string or a list of them and return it as a list.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, string>
+ * @throws InvalidArgumentException If the value is not a non-empty string or a list of them.
+ */
+ public static function validate_string_list( string $key, $value ): array {
+ $values = is_string( $value ) ? array( $value ) : $value;
+ if ( ! self::is_list( $values ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string or a list of them.', esc_html( $key ) ) );
+ }
+
+ $strings = array();
+ foreach ( $values as $item ) {
+ if ( ! is_string( $item ) || '' === $item ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string or a list of them.', esc_html( $key ) ) );
+ }
+ $strings[] = $item;
+ }
+
+ return $strings;
+ }
+
+ /**
+ * Validate and return an array, or null.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int|string, mixed>|null
+ * @throws InvalidArgumentException If the value is not null or an array.
+ */
+ public static function validate_nullable_array( string $key, $value ): ?array {
+ if ( null !== $value && ! is_array( $value ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be null or an array.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
/**
* Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
*
@@ -174,7 +268,7 @@ final class ArgumentValidator {
* @throws InvalidArgumentException If the value is not a list of arrays.
*/
public static function validate_list_of_arrays( string $key, $value ): array {
- if ( ! is_array( $value ) || ( array() !== $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) {
+ if ( ! self::is_list( $value ) ) {
throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
}
@@ -233,4 +327,28 @@ final class ArgumentValidator {
return $addresses;
}
+
+ /**
+ * Whether a value is a list: an array with consecutive int keys from 0 (an empty array is one).
+ *
+ * @param mixed $value Caller value.
+ * @phpstan-assert-if-true array<int, mixed> $value
+ */
+ private static function is_list( $value ): bool {
+ return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) );
+ }
+
+ /**
+ * Cast a digit string to an int; any other value is returned unchanged.
+ *
+ * @param mixed $value Caller value.
+ * @return mixed
+ */
+ private static function cast_digit_string( $value ) {
+ if ( is_string( $value ) && ctype_digit( $value ) ) {
+ return (int) $value;
+ }
+
+ return $value;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
index e7ff1730f5c..9f794ac9712 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -22,11 +22,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
@@ -1159,15 +1156,7 @@ class ContractsTest extends EngineIntegrationTestCase {
* @return Contract The persisted contract with cycle 1 billed.
*/
private function sign_up_contract( int $customer_id = 0 ): Contract {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'engine-tests',
- )
- );
- ( new PlanRepository() )->insert( $plan );
+ $plan = $this->plan_view( $this->make_plan() );
$order = new WC_Order();
$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
new file mode 100644
index 00000000000..c68a368c62d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
@@ -0,0 +1,1112 @@
+<?php
+/**
+ * Integration tests for the Plans facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use DomainException;
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use RuntimeException;
+use WP_Error;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException
+ */
+class PlansTest extends EngineIntegrationTestCase {
+
+ private const OWNER = 'acme-subs';
+
+ private const HOOK = 'woocommerce_subscriptions_engine_validate_plan';
+
+ public function tear_down(): void {
+ remove_all_actions( self::HOOK );
+ StatusRegistry::reset();
+ parent::tear_down();
+ }
+
+ /**
+ * Create a plan with defaults.
+ *
+ * @param array<string, mixed> $overrides Arg overrides.
+ */
+ private function create( array $overrides = array() ): int {
+ $plan = Plans::create(
+ array_merge(
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Monthly',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ ),
+ $overrides
+ )
+ );
+
+ return $plan->get_id();
+ }
+
+ /**
+ * Load a stored plan.
+ *
+ * @param int $id Plan id.
+ */
+ private function stored( int $id ): Plan {
+ $plan = ( new PlanRepository() )->find( $id );
+ $this->assertInstanceOf( Plan::class, $plan );
+
+ return $plan;
+ }
+
+ /**
+ * Number of stored plans.
+ */
+ private function plan_count(): int {
+ return ( new PlanRepository() )->count();
+ }
+
+ /**
+ * @testdox create stores the fields and returns the view.
+ */
+ public function test_create_stores_the_fields_and_returns_the_view(): void {
+ $created = Plans::create(
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => ' Box ',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ 'pricing_policy' => array( 'policies' => array() ),
+ 'delivery_policy' => array( 'anchor' => 1 ),
+ )
+ );
+ $id = $created->get_id();
+
+ $this->assertEquals( Plans::get( $id ), $created, 'The returned view matches a fresh read.' );
+ $this->assertNotNull( $created->get_date_created_gmt() );
+
+ $plan = $this->stored( $id );
+ $this->assertSame( 'Box', $plan->get_name() );
+ $this->assertSame( self::OWNER, $plan->get_extension_slug() );
+ $this->assertSame( PlanStatus::ACTIVE, $plan->get_status() );
+ $this->assertSame(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ $plan->get_billing_policy()
+ );
+ $this->assertSame( array( 'policies' => array() ), $plan->get_pricing_policy() );
+ $this->assertSame( array( 'anchor' => 1 ), $plan->get_delivery_policy() );
+ }
+
+ /**
+ * @testdox create without policies stores nulls.
+ */
+ public function test_create_without_policies_stores_nulls(): void {
+ $created = Plans::create(
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Bare',
+ )
+ );
+ $plan = $this->stored( $created->get_id() );
+
+ $this->assertNull( $plan->get_billing_policy() );
+ $this->assertNull( $plan->get_pricing_policy() );
+ $this->assertNull( $plan->get_delivery_policy() );
+ }
+
+ /**
+ * @testdox create accepts a registered extension status.
+ */
+ public function test_create_accepts_a_registered_extension_status(): void {
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
+ $this->assertSame( 'seasonal', $this->stored( $this->create( array( 'status' => 'seasonal' ) ) )->get_status() );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_create_args(): array {
+ return array(
+ 'unregistered status' => array( array( 'status' => 'seasonal' ) ),
+ 'non-string status' => array( array( 'status' => 5 ) ),
+ 'empty name' => array( array( 'name' => '' ) ),
+ 'whitespace name' => array( array( 'name' => ' ' ) ),
+ 'non-string name' => array( array( 'name' => 12 ) ),
+ 'list policy' => array( array( 'pricing_policy' => array( 'a', 'b' ) ) ),
+ 'scalar policy' => array( array( 'billing_policy' => 'monthly' ) ),
+ 'missing slug' => array( array( 'extension_slug' => null ) ),
+ 'empty slug' => array( array( 'extension_slug' => '' ) ),
+ );
+ }
+
+ /**
+ * @testdox create rejects invalid args and stores nothing.
+ * @dataProvider provide_invalid_create_args
+ *
+ * @param array<string, mixed> $overrides Invalid overrides.
+ */
+ public function test_create_rejects_invalid_args_and_stores_nothing( array $overrides ): void {
+ $before = $this->plan_count();
+
+ try {
+ $this->create( $overrides );
+ $this->fail( 'Expected InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertNotInstanceOf( PlanValidationException::class, $e );
+ }
+
+ $this->assertSame( $before, $this->plan_count() );
+ }
+
+ /**
+ * @testdox an entity invariant failure is reported as invalid input, on create and on update.
+ */
+ public function test_an_entity_invariant_failure_is_reported_as_invalid_input(): void {
+ try {
+ $this->create( array( 'status' => 'nonsense' ) );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertInstanceOf( DomainException::class, $e->getPrevious() );
+ $this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
+ }
+
+ try {
+ Plans::update(
+ $this->create(),
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => ' ',
+ )
+ );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertInstanceOf( DomainException::class, $e->getPrevious() );
+ }
+ }
+
+ /**
+ * @testdox create requires a name.
+ */
+ public function test_create_requires_a_name(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Plans::create( array( 'extension_slug' => self::OWNER ) );
+ }
+
+ /**
+ * @testdox an unknown create key is ignored with a notice.
+ */
+ public function test_an_unknown_create_key_is_ignored_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( Plans::class . '::create' );
+ $messages = array();
+ add_action(
+ 'doing_it_wrong_run',
+ static function ( $function_name, $message ) use ( &$messages ): void {
+ $messages[] = $message;
+ },
+ 10,
+ 2
+ );
+
+ $id = $this->create( array( 'sort_order' => 1 ) );
+
+ $this->assertSame( array( 'Unknown key "sort_order" ignored.' ), $messages );
+ $this->assertSame( 'Monthly', $this->stored( $id )->get_name(), 'The known keys beside the unknown one are written.' );
+ }
+
+ /**
+ * @testdox update replaces a policy wholesale and null clears.
+ */
+ public function test_update_replaces_a_policy_wholesale_and_null_clears(): void {
+ $id = $this->create(
+ array(
+ 'pricing_policy' => array(
+ 'a' => 1,
+ 'b' => 2,
+ ),
+ )
+ );
+
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'pricing_policy' => array( 'c' => 3 ),
+ )
+ )
+ );
+ $plan = $this->stored( $id );
+ $this->assertSame( array( 'c' => 3 ), $plan->get_pricing_policy() );
+ $this->assertSame(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ $plan->get_billing_policy(),
+ 'An omitted policy keeps its stored payload.'
+ );
+
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'pricing_policy' => null,
+ )
+ )
+ );
+ $this->assertNull( $this->stored( $id )->get_pricing_policy() );
+ }
+
+ /**
+ * @testdox a status-only update writes the status and keeps the name.
+ */
+ public function test_status_only_update(): void {
+ $id = $this->create();
+
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'status' => PlanStatus::ARCHIVED,
+ )
+ )
+ );
+
+ $plan = $this->stored( $id );
+ $this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+ $this->assertSame( 'Monthly', $plan->get_name() );
+ }
+
+ /**
+ * @testdox update of a missing plan returns null.
+ */
+ public function test_update_of_a_missing_plan_returns_null(): void {
+ $this->assertNull(
+ Plans::update(
+ 999999,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Nope',
+ )
+ )
+ );
+ $this->assertNull(
+ Plans::update(
+ 0,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Nope',
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox update returns the plan as read plus the written fields, matching a fresh read (stored update time included).
+ */
+ public function test_update_returns_the_view_with_the_written_fields(): void {
+ $id = $this->create();
+
+ $updated = Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Renamed',
+ )
+ );
+
+ $this->assertInstanceOf( PlanView::class, $updated );
+ $this->assertSame( 'Renamed', $updated->get_name() );
+ $this->assertEquals( Plans::get( $id ), $updated, 'The returned view matches a fresh read.' );
+ }
+
+ /**
+ * @testdox update returns null when the plan is deleted before the write.
+ */
+ public function test_update_returns_null_when_the_plan_is_deleted_before_the_write(): void {
+ global $wpdb;
+
+ $id = $this->create();
+
+ // A concurrent delete lands after the facade read the plan and before its write.
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ $injected = false;
+ $race = static function ( string $query ) use ( &$injected, $table, $id, $wpdb ): string {
+ if ( ! $injected && 0 === stripos( ltrim( $query ), 'UPDATE' ) && false !== strpos( $query, $table ) ) {
+ $injected = true;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( $table, array( 'id' => $id ) );
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $race );
+
+ try {
+ $updated = Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Renamed',
+ )
+ );
+ } finally {
+ remove_filter( 'query', $race );
+ }
+
+ $this->assertTrue( $injected );
+ $this->assertNull( $updated );
+ }
+
+ /**
+ * @testdox an unknown update key is ignored with a notice.
+ */
+ public function test_an_unknown_update_key_is_ignored_with_a_notice(): void {
+ $id = $this->create();
+ $this->setExpectedIncorrectUsage( Plans::class . '::update' );
+
+ $updated = Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'sort_order' => 1,
+ 'name' => 'Changed',
+ )
+ );
+ $this->assertInstanceOf( PlanView::class, $updated );
+
+ $this->assertSame( 'Changed', $this->stored( $id )->get_name(), 'The known key beside the unknown one is written.' );
+ }
+
+ /**
+ * @testdox the extension slug passed to update is not written.
+ */
+ public function test_the_extension_slug_passed_to_update_is_not_written(): void {
+ $id = $this->create();
+
+ $queries = array();
+ $capture = static function ( string $query ) use ( &$queries ): string {
+ $queries[] = $query;
+
+ return $query;
+ };
+ add_filter( 'query', $capture );
+
+ try {
+ $updated = Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Changed',
+ )
+ );
+ } finally {
+ remove_filter( 'query', $capture );
+ }
+
+ $this->assertInstanceOf( PlanView::class, $updated );
+ $this->assertSame( self::OWNER, $updated->get_extension_slug() );
+
+ // Scoping (the WHERE side) is pinned by the cross-extension update tests; the SQL
+ // check covers only what stored state cannot show: the slug is never in a SET list.
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ $updates = array_values(
+ array_filter(
+ $queries,
+ static function ( string $query ) use ( $table ): bool {
+ return 0 === stripos( ltrim( $query ), 'UPDATE' ) && false !== strpos( $query, $table );
+ }
+ )
+ );
+ $this->assertNotEmpty( $updates );
+ foreach ( $updates as $update ) {
+ $where = stripos( $update, ' WHERE ' );
+ $set_list = false === $where ? $update : substr( $update, 0, $where );
+ $this->assertDoesNotMatchRegularExpression( '/\bextension_slug\b/i', $set_list, 'The slug is not in the SET list.' );
+ }
+
+ $plan = $this->stored( $id );
+ $this->assertSame( self::OWNER, $plan->get_extension_slug() );
+ $this->assertSame( 'Changed', $plan->get_name() );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_update_scopes(): array {
+ return array(
+ 'missing slug' => array( array( 'name' => 'Changed' ) ),
+ 'null slug' => array(
+ array(
+ 'extension_slug' => null,
+ 'name' => 'Changed',
+ ),
+ ),
+ 'empty slug' => array(
+ array(
+ 'extension_slug' => '',
+ 'name' => 'Changed',
+ ),
+ ),
+ 'non-string slug' => array(
+ array(
+ 'extension_slug' => 5,
+ 'name' => 'Changed',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox update without a valid extension slug throws and leaves the plan unchanged.
+ * @dataProvider provide_invalid_update_scopes
+ *
+ * @param array<string, mixed> $args Update args without a valid extension slug.
+ */
+ public function test_update_without_a_valid_extension_slug_throws( array $args ): void {
+ $id = $this->create();
+ $before = $this->stored( $id )->to_storage();
+
+ try {
+ Plans::update( $id, $args );
+ $this->fail( 'Expected InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertNotInstanceOf( PlanValidationException::class, $e );
+ }
+
+ $this->assertSame( $before, $this->stored( $id )->to_storage() );
+ }
+
+ /**
+ * @testdox update under another extension slug returns null, leaves the plan unchanged and never asks that extension to validate.
+ */
+ public function test_update_under_another_extension_slug_returns_null_and_writes_nothing(): void {
+ $id = $this->create();
+ $before = $this->stored( $id )->to_storage();
+
+ $validated = 0;
+ add_action(
+ self::HOOK,
+ static function () use ( &$validated ): void {
+ ++$validated;
+ }
+ );
+
+ $this->assertNull(
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => 'other-extension',
+ 'name' => 'Hijacked',
+ )
+ )
+ );
+ $this->assertNull( Plans::update( $id, array( 'extension_slug' => 'other-extension' ) ), 'A fieldless update is scoped too.' );
+
+ $this->assertSame( 0, $validated, 'The validate action never sees a plan of another extension.' );
+ $this->assertSame( $before, $this->stored( $id )->to_storage() );
+ }
+
+ /**
+ * @testdox an update writing identical values within the same second still returns the view.
+ */
+ public function test_an_identical_update_within_the_same_second_returns_the_view(): void {
+ $id = $this->create();
+ $args = array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Same',
+ );
+
+ // The first write may change the row; the second, within the same second, changes none.
+ $views = array( Plans::update( $id, $args ), Plans::update( $id, $args ) );
+
+ foreach ( $views as $view ) {
+ $this->assertInstanceOf( PlanView::class, $view );
+ $this->assertSame( 'Same', $view->get_name() );
+ }
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_update_args(): array {
+ return array(
+ 'unregistered status' => array( array( 'status' => 'seasonal' ) ),
+ 'non-string status' => array( array( 'status' => 5 ) ),
+ 'empty name' => array( array( 'name' => '' ) ),
+ 'whitespace name' => array( array( 'name' => ' ' ) ),
+ 'non-string name' => array( array( 'name' => 12 ) ),
+ 'list policy' => array( array( 'pricing_policy' => array( 'a', 'b' ) ) ),
+ 'scalar policy' => array( array( 'billing_policy' => 'monthly' ) ),
+ 'valid then invalid' => array(
+ array(
+ 'name' => 'Changed',
+ 'status' => 'seasonal',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox update rejects invalid args and leaves the plan unchanged.
+ * @dataProvider provide_invalid_update_args
+ *
+ * @param array<string, mixed> $args Invalid update args.
+ */
+ public function test_update_rejects_invalid_args_and_leaves_the_plan_unchanged( array $args ): void {
+ $id = $this->create();
+ $before = $this->stored( $id )->to_storage();
+
+ try {
+ Plans::update( $id, array( 'extension_slug' => self::OWNER ) + $args );
+ $this->fail( 'Expected InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertNotInstanceOf( PlanValidationException::class, $e );
+ }
+
+ $this->assertSame( $before, $this->stored( $id )->to_storage() );
+ }
+
+ /**
+ * @testdox update accepts the stored status after it is unregistered.
+ */
+ public function test_update_accepts_the_stored_status_after_it_is_unregistered(): void {
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+ $id = $this->create( array( 'status' => 'seasonal' ) );
+ StatusRegistry::reset();
+ $this->assertFalse( PlanStatus::is_registered( 'seasonal' ) );
+
+ $updated = Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'status' => 'seasonal',
+ 'name' => 'Renamed',
+ )
+ );
+ $this->assertInstanceOf( PlanView::class, $updated );
+
+ $plan = $this->stored( $id );
+ $this->assertSame( 'seasonal', $plan->get_status() );
+ $this->assertSame( 'Renamed', $plan->get_name() );
+ }
+
+ /**
+ * @testdox update writes only the present fields.
+ */
+ public function test_update_writes_only_the_present_fields(): void {
+ global $wpdb;
+
+ $id = $this->create();
+
+ // A concurrent writer renames the plan while the extension validates a status change.
+ add_action(
+ self::HOOK,
+ static function () use ( $wpdb, $id ): void {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ), array( 'name' => 'Renamed elsewhere' ), array( 'id' => $id ) );
+ }
+ );
+
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'status' => PlanStatus::ARCHIVED,
+ )
+ )
+ );
+
+ $plan = $this->stored( $id );
+ $this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+ $this->assertSame( 'Renamed elsewhere', $plan->get_name(), 'The status-only update must not write the name it read.' );
+ }
+
+ /**
+ * @testdox update with no fields returns the plan without validating or writing.
+ */
+ public function test_update_with_no_fields_returns_the_plan_without_validating_or_writing(): void {
+ global $wpdb;
+
+ $id = $this->create();
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET date_updated_gmt = %s WHERE id = %d", '2020-01-01 00:00:00', $id ) );
+
+ $validated = 0;
+ add_action(
+ self::HOOK,
+ static function () use ( &$validated ): void {
+ ++$validated;
+ }
+ );
+
+ $this->assertInstanceOf( PlanView::class, Plans::update( $id, array( 'extension_slug' => self::OWNER ) ) );
+ $this->assertSame( 0, $validated, 'An empty update has nothing to validate.' );
+ $this->assertSame( '2020-01-01 00:00:00', $this->stored( $id )->get_date_updated_gmt(), 'An empty update writes nothing.' );
+
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Renamed',
+ )
+ )
+ );
+ $this->assertNotSame( '2020-01-01 00:00:00', $this->stored( $id )->get_date_updated_gmt(), 'A field update bumps the update time.' );
+ }
+
+ /**
+ * @testdox the validate action receives the would-be state on create.
+ */
+ public function test_validate_action_receives_the_would_be_state_on_create(): void {
+ $seen = array();
+ add_action(
+ self::HOOK,
+ static function ( $errors, $plan, $extension_slug ) use ( &$seen ): void {
+ $seen[] = array( $errors, $plan, $extension_slug );
+ },
+ 10,
+ 3
+ );
+
+ $this->create( array( 'name' => 'Seen' ) );
+
+ $this->assertCount( 1, $seen );
+ $this->assertInstanceOf( WP_Error::class, $seen[0][0] );
+ $this->assertInstanceOf( PlanView::class, $seen[0][1] );
+ $this->assertSame( 0, $seen[0][1]->get_id() );
+ $this->assertSame( 'Seen', $seen[0][1]->get_name() );
+ $this->assertSame( self::OWNER, $seen[0][1]->get_extension_slug() );
+ $this->assertSame( self::OWNER, $seen[0][2] );
+ }
+
+ /**
+ * @testdox the validate action receives the would-be state on update.
+ */
+ public function test_validate_action_receives_the_would_be_state_on_update(): void {
+ $id = $this->create();
+ $seen = null;
+ add_action(
+ self::HOOK,
+ static function ( $errors, $plan ) use ( &$seen ): void {
+ unset( $errors );
+ $seen = $plan;
+ },
+ 10,
+ 2
+ );
+
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Renamed',
+ 'pricing_policy' => array( 'x' => 1 ),
+ )
+ );
+
+ $this->assertInstanceOf( PlanView::class, $seen );
+ $this->assertSame( $id, $seen->get_id() );
+ $this->assertSame( 'Renamed', $seen->get_name() );
+ $this->assertSame( array( 'x' => 1 ), $seen->get_pricing_policy() );
+ }
+
+ /**
+ * @testdox an added error refuses the create with its codes.
+ */
+ public function test_an_added_error_refuses_the_create_with_its_codes(): void {
+ $before = $this->plan_count();
+ add_action(
+ self::HOOK,
+ static function ( $errors ): void {
+ $errors->add( 'acme_bad_pricing', 'Pricing is wrong.', array( 'status' => 400 ) );
+ }
+ );
+ add_action(
+ self::HOOK,
+ static function ( $errors ): void {
+ $errors->add( 'acme_bad_billing', 'Billing is wrong.' );
+ }
+ );
+
+ try {
+ $this->create();
+ $this->fail( 'Expected PlanValidationException.' );
+ } catch ( PlanValidationException $e ) {
+ $this->assertSame( array( 'acme_bad_pricing', 'acme_bad_billing' ), $e->get_errors()->get_error_codes() );
+ $this->assertSame( array( 'status' => 400 ), $e->get_errors()->get_error_data( 'acme_bad_pricing' ) );
+ $this->assertSame( 'Pricing is wrong. Billing is wrong.', $e->getMessage() );
+ }
+
+ $this->assertSame( $before, $this->plan_count() );
+ }
+
+ /**
+ * @testdox an added error refuses the update.
+ */
+ public function test_an_added_error_refuses_the_update(): void {
+ $id = $this->create();
+ add_action(
+ self::HOOK,
+ static function ( $errors ): void {
+ $errors->add( 'acme_no', 'No.' );
+ }
+ );
+
+ try {
+ Plans::update(
+ $id,
+ array(
+ 'extension_slug' => self::OWNER,
+ 'name' => 'Refused',
+ )
+ );
+ $this->fail( 'Expected PlanValidationException.' );
+ } catch ( PlanValidationException $e ) {
+ $this->assertSame( array( 'acme_no' ), $e->get_errors()->get_error_codes() );
+ }
+
+ $this->assertSame( 'Monthly', $this->stored( $id )->get_name() );
+ }
+
+ /**
+ * @testdox a throwing callback throws a runtime exception and stores nothing.
+ */
+ public function test_a_throwing_callback_throws_a_runtime_exception_and_stores_nothing(): void {
+ $before = $this->plan_count();
+ add_action(
+ self::HOOK,
+ static function (): void {
+ throw new \LogicException( 'boom' );
+ }
+ );
+
+ try {
+ $this->create();
+ $this->fail( 'Expected RuntimeException.' );
+ } catch ( RuntimeException $e ) {
+ $this->assertInstanceOf( \LogicException::class, $e->getPrevious() );
+ }
+
+ $this->assertSame( $before, $this->plan_count() );
+ }
+
+ /**
+ * @testdox a failed insert throws a runtime exception without a previous exception.
+ */
+ public function test_a_failed_insert_throws_a_runtime_exception_without_a_previous_exception(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ $break_insert = static function ( string $query ) use ( $table ): string {
+ if ( 0 === stripos( ltrim( $query ), 'INSERT' ) && false !== strpos( $query, $table ) ) {
+ return 'INSERT INTO nonexistent_table_for_this_test (id) VALUES (1)';
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $break_insert );
+ $suppressed = $wpdb->suppress_errors( true );
+
+ try {
+ $this->create();
+ $this->fail( 'Expected RuntimeException.' );
+ } catch ( RuntimeException $e ) {
+ // The REST controller reads a previous exception as "a validation callback threw".
+ $this->assertNull( $e->getPrevious() );
+ } finally {
+ $wpdb->suppress_errors( $suppressed );
+ remove_filter( 'query', $break_insert );
+ }
+ }
+
+ /**
+ * @testdox the meta methods round-trip values.
+ */
+ public function test_meta_methods_round_trip(): void {
+ $id = $this->create();
+
+ $this->assertIsInt( Plans::add_meta( $id, 'note', 'one' ) );
+ $this->assertNull( Plans::add_meta( $id, 'note', 'two', true ) );
+ $this->assertTrue( Plans::update_meta( $id, 'flag', array( 'a' => 1 ) ) );
+ $this->assertSame( array( 'one' ), Plans::get_meta( $id, 'note' ) );
+ $this->assertSame( array( 'a' => 1 ), Plans::get_meta( $id, 'flag', true ) );
+ $this->assertSame(
+ array(
+ 'note' => array( 'one' ),
+ 'flag' => array( array( 'a' => 1 ) ),
+ ),
+ Plans::get_meta( $id )
+ );
+ $this->assertTrue( Plans::delete_meta( $id, 'note' ) );
+ $this->assertSame( '', Plans::get_meta( $id, 'note', true ) );
+ }
+
+ /**
+ * @testdox meta reads for a missing plan are empty.
+ */
+ public function test_meta_reads_for_a_missing_plan_are_empty(): void {
+ $this->assertFalse( Plans::delete_meta( 999999, 'note' ) );
+ $this->assertSame( '', Plans::get_meta( 999999, 'note', true ) );
+ $this->assertSame( array(), Plans::get_meta( 999999 ) );
+ }
+
+ /**
+ * @testdox meta writes do not look up the plan.
+ */
+ public function test_meta_writes_do_not_look_up_the_plan(): void {
+ $id = $this->create();
+ Plans::add_meta( $id, 'note', 'one' );
+
+ $queries = array();
+ $capture = static function ( $query ) use ( &$queries ) {
+ $queries[] = $query;
+ return $query;
+ };
+ add_filter( 'query', $capture );
+ try {
+ Plans::add_meta( $id, 'note', 'two' );
+ Plans::update_meta( $id, 'flag', 'on' );
+ } finally {
+ remove_filter( 'query', $capture );
+ }
+
+ $plans_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ foreach ( $queries as $query ) {
+ $this->assertDoesNotMatchRegularExpression( '/\\b' . preg_quote( $plans_table, '/' ) . '\\b/', $query, 'A meta write reads only the meta table.' );
+ }
+ $this->assertSame( array( 'one', 'two' ), Plans::get_meta( $id, 'note' ) );
+ $this->assertSame( 'on', Plans::get_meta( $id, 'flag', true ) );
+ }
+
+ /**
+ * @testdox deleting a plan removes its meta.
+ */
+ public function test_deleting_a_plan_removes_its_meta(): void {
+ $id = $this->create();
+ Plans::add_meta( $id, 'note', 'one' );
+
+ $this->assertTrue( ( new PlanRepository() )->delete( $id ) );
+
+ $this->assertSame( array(), Plans::get_meta( $id, 'note' ) );
+ }
+
+ /**
+ * @testdox an empty meta key is rejected.
+ */
+ public function test_an_empty_meta_key_is_rejected(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Plans::add_meta( $this->create(), '', 'x' );
+ }
+
+ /**
+ * Map views to their ids.
+ *
+ * @param array<int, PlanView> $plans Views to map.
+ * @return array<int, int>
+ */
+ private static function plan_ids( array $plans ): array {
+ return array_map(
+ static function ( PlanView $plan ): int {
+ return $plan->get_id();
+ },
+ $plans
+ );
+ }
+
+ /**
+ * @testdox get returns a plan in any status, and null for an unknown id.
+ */
+ public function test_get_returns_a_plan_in_any_status(): void {
+ $archived_id = $this->create(
+ array(
+ 'status' => PlanStatus::ARCHIVED,
+ 'pricing_policy' => array( 'opaque' => true ),
+ )
+ );
+
+ $plan = Plans::get( $archived_id );
+
+ $this->assertInstanceOf( PlanView::class, $plan );
+ $this->assertSame( $archived_id, $plan->get_id() );
+ $this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+ $this->assertSame( self::OWNER, $plan->get_extension_slug() );
+ $this->assertSame( array( 'opaque' => true ), $plan->get_pricing_policy() );
+ $this->assertNull( Plans::get( 999999 ) );
+ $this->assertNull( Plans::get( 0 ) );
+ }
+
+ /**
+ * @testdox list without args returns every extension's plans in every status, oldest id first.
+ */
+ public function test_list_without_args_returns_every_plan_in_id_order(): void {
+ $first_id = $this->create( array( 'name' => 'Zulu' ) );
+ $archived_id = $this->create( array( 'status' => PlanStatus::ARCHIVED ) );
+ $foreign_id = $this->create( array( 'extension_slug' => 'other-extension' ) );
+
+ $plans = Plans::list();
+
+ $this->assertSame( array( $first_id, $archived_id, $foreign_id ), self::plan_ids( $plans ) );
+ $this->assertContainsOnlyInstancesOf( PlanView::class, $plans );
+ }
+
+ /**
+ * @testdox list filters by an extension slug or a list of them, ignoring duplicate slugs.
+ */
+ public function test_list_filters_by_extension_slug(): void {
+ $own_id = $this->create();
+ $other_id = $this->create( array( 'extension_slug' => 'other-extension' ) );
+ $this->create( array( 'extension_slug' => 'third-extension' ) );
+
+ $this->assertSame( array( $own_id ), self::plan_ids( Plans::list( array( 'extension_slug' => self::OWNER ) ) ) );
+ $this->assertSame(
+ array( $own_id, $other_id ),
+ self::plan_ids( Plans::list( array( 'extension_slug' => array( self::OWNER, 'other-extension' ) ) ) )
+ );
+ $this->assertSame( array( $own_id ), self::plan_ids( Plans::list( array( 'extension_slug' => array( self::OWNER, self::OWNER ) ) ) ) );
+ }
+
+ /**
+ * @testdox list filters by a status or a list of them.
+ */
+ public function test_list_filters_by_status(): void {
+ $active_id = $this->create();
+ $archived_id = $this->create( array( 'status' => PlanStatus::ARCHIVED ) );
+
+ $this->assertSame( array( $active_id ), self::plan_ids( Plans::list( array( 'status' => PlanStatus::ACTIVE ) ) ) );
+ $this->assertSame(
+ array( $active_id, $archived_id ),
+ self::plan_ids( Plans::list( array( 'status' => array( PlanStatus::ACTIVE, PlanStatus::ARCHIVED ) ) ) )
+ );
+ }
+
+ /**
+ * @testdox list filters by ids in id order regardless of the requested order, composing with the other filters.
+ */
+ public function test_list_filters_by_ids(): void {
+ $first_id = $this->create();
+ $second_id = $this->create();
+ $excluded_id = $this->create();
+ $archived_id = $this->create( array( 'status' => PlanStatus::ARCHIVED ) );
+ $foreign_id = $this->create( array( 'extension_slug' => 'other-extension' ) );
+ $requested = array( $archived_id, $second_id, $first_id, $foreign_id, 999999 );
+
+ $plans = Plans::list(
+ array(
+ 'extension_slug' => self::OWNER,
+ 'ids' => $requested,
+ )
+ );
+ $all = self::plan_ids( $plans );
+ $this->assertSame( array( $first_id, $second_id, $archived_id ), $all );
+ $this->assertNotContains( $excluded_id, $all );
+
+ $active_plans = Plans::list(
+ array(
+ 'extension_slug' => self::OWNER,
+ 'ids' => $requested,
+ 'status' => PlanStatus::ACTIVE,
+ )
+ );
+ $this->assertSame( array( $first_id, $second_id ), self::plan_ids( $active_plans ) );
+ }
+
+ /**
+ * @testdox list matches nothing for an empty list filter.
+ */
+ public function test_list_matches_nothing_for_an_empty_list_filter(): void {
+ $this->create();
+
+ $this->assertSame( array(), Plans::list( array( 'ids' => array() ) ) );
+ $this->assertSame( array(), Plans::list( array( 'status' => array() ) ) );
+ $this->assertSame( array(), Plans::list( array( 'extension_slug' => array() ) ) );
+ }
+
+ /**
+ * @testdox list pages with limit and offset in id order.
+ */
+ public function test_list_pages_with_limit_and_offset(): void {
+ $this->create();
+ $second_id = $this->create();
+ $third_id = $this->create();
+
+ $plans = Plans::list(
+ array(
+ 'limit' => 2,
+ 'offset' => 1,
+ )
+ );
+ $this->assertSame( array( $second_id, $third_id ), self::plan_ids( $plans ) );
+ }
+
+ /**
+ * @testdox list ignores an unknown key with a notice.
+ */
+ public function test_list_ignores_an_unknown_key_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( Plans::class . '::list' );
+ $plan_id = $this->create();
+
+ $this->assertSame( array( $plan_id ), self::plan_ids( Plans::list( array( 'orderby' => 'name' ) ) ) );
+ }
+
+ /**
+ * @testdox list rejects an invalid value.
+ * @dataProvider provide_invalid_list_args
+ *
+ * @param array<string, mixed> $args List args.
+ */
+ public function test_list_rejects_an_invalid_value( array $args ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Plans::list( $args );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_list_args(): array {
+ return array(
+ 'empty extension slug' => array( array( 'extension_slug' => '' ) ),
+ 'any extension slug' => array( array( 'extension_slug' => 'any' ) ),
+ 'any in a slug list' => array( array( 'extension_slug' => array( self::OWNER, 'any' ) ) ),
+ 'status not a string' => array( array( 'status' => 5 ) ),
+ 'id not positive' => array( array( 'ids' => array( 1, 0 ) ) ),
+ 'ids not a list' => array( array( 'ids' => array( 'a' => 1 ) ) ),
+ 'zero limit' => array( array( 'limit' => 0 ) ),
+ 'negative offset' => array( array( 'offset' => -1 ) ),
+ );
+ }
+}
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
index 74a89f0916e..ff41ae8a449 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
@@ -9,8 +9,12 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api\Rest;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use EngineIntegrationTestCase;
use RuntimeException;
use WP_Error;
@@ -45,6 +49,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
public function tearDown(): void {
remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
+ StatusRegistry::reset();
wp_set_current_user( 0 );
parent::tearDown();
}
@@ -57,25 +62,26 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertSame( 401, $response->get_status() );
}
- public function test_create_list_and_partial_patch_preserves_advanced_pricing_fields(): void {
+ public function test_patch_replaces_a_policy_wholesale_and_keeps_omitted_policies(): void {
wp_set_current_user( $this->admin_id );
+ $billing = array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'max_cycles' => 12,
+ 'trial_duration' => array(
+ 'length' => 7,
+ 'unit' => 'day',
+ ),
+ );
+
$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',
- ),
- ),
+ 'billing_policy' => $billing,
'pricing_policy' => array(
'policies' => array(
array(
@@ -85,10 +91,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
),
'one_time_fees' => array(
array(
- 'kind' => 'setup',
- 'amount' => 5,
- 'taxable' => true,
- 'tax_class' => '',
+ 'kind' => 'setup',
+ 'amount' => 5,
),
),
),
@@ -97,11 +101,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$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( PlanStatus::ACTIVE, $created_data['status'] );
$this->assertSame( self::EXTENSION_SLUG, $created_data['extension_slug'] );
- $this->assertArrayNotHasKey( 'group', $created_data );
- $this->assertArrayNotHasKey( 'merchant_code', $created_data );
$id = $this->int_value( $created_data, 'id' );
@@ -111,59 +112,21 @@ class PlansControllerTest extends EngineIntegrationTestCase {
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,
- ),
- ),
+ 'policies' => array( array( 'type' => 'bogo' ) ),
),
)
);
$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 );
+ $patched_data = $this->response_data( $patched );
$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']
- );
- // Stored as sent: the engine coerces nothing inside the payload.
- $this->assertSame(
- array(
- 'type' => 'fixed_amount',
- 'value' => 2,
- 'duration_cycles' => 3,
- ),
- $first_policy
- );
- $this->assertSame(
- array(
- 'kind' => 'setup',
- 'amount' => 5,
- 'taxable' => true,
- 'tax_class' => '',
- ),
- $first_fee
- );
+ $this->assertSame( array( 'policies' => array( array( 'type' => 'bogo' ) ) ), $patched_data['pricing_policy'], 'A present policy replaces the stored payload; nothing is merged.' );
+ $this->assertSame( $billing, $patched_data['billing_policy'], 'An omitted policy keeps its stored payload.' );
+
+ $fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+ $this->assertSame( array( 'policies' => array( array( 'type' => 'bogo' ) ) ), $fetched['pricing_policy'] );
+ $this->assertSame( $billing, $fetched['billing_policy'] );
$list = $this->request(
'GET',
@@ -179,6 +142,87 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertCount( 1, $this->response_data( $list ) );
}
+ public function test_billing_and_delivery_round_trip_opaquely(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $billing = array(
+ 'cadence' => 'every full moon',
+ 'nested' => array( 'anything' => array( 1, 2 ) ),
+ );
+ $delivery = array(
+ 'anchor' => array( 'weekday' => 'tue' ),
+ 'note' => 'opaque',
+ );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Opaque',
+ 'billing_policy' => $billing,
+ 'delivery_policy' => $delivery,
+ )
+ );
+
+ $this->assertSame( 201, $created->get_status(), 'The engine does not validate cadence.' );
+ $id = $this->int_value( $this->response_data( $created ), 'id' );
+
+ $fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+ $this->assertSame( $billing, $fetched['billing_policy'] );
+ $this->assertSame( $delivery, $fetched['delivery_policy'] );
+ }
+
+ public function test_create_without_billing_policy_stores_null(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'No billing',
+ )
+ );
+
+ $this->assertSame( 201, $created->get_status() );
+ $data = $this->response_data( $created );
+ $this->assertNull( $data['billing_policy'] );
+ $this->assertNull( $data['pricing_policy'] );
+ $this->assertNull( $data['delivery_policy'] );
+ }
+
+ public function test_response_has_exactly_the_record_fields(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $id = $this->create_plan( 'Fields' );
+ $data = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+
+ $this->assertEqualsCanonicalizing(
+ array( 'id', 'extension_slug', 'status', 'name', 'billing_policy', 'pricing_policy', 'delivery_policy', 'date_created_gmt', 'date_updated_gmt' ),
+ array_keys( $data )
+ );
+ $this->assertIsString( $data['date_created_gmt'] );
+ $this->assertIsString( $data['date_updated_gmt'] );
+ }
+
+ public function test_an_empty_policy_object_is_returned_as_a_json_object(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Empty pricing',
+ 'pricing_policy' => array(),
+ )
+ );
+
+ $this->assertSame( 201, $created->get_status() );
+ $this->assertSame( '{}', wp_json_encode( $this->response_data( $created )['pricing_policy'] ) );
+ }
+
public function test_create_round_trips_the_pricing_payload_opaquely(): void {
wp_set_current_user( $this->admin_id );
@@ -218,72 +262,6 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertSame( $pricing_policy, $this->response_data( $fetched )['pricing_policy'] );
}
- public function test_update_replaces_provided_top_level_keys_and_keeps_omitted_ones(): void {
- wp_set_current_user( $this->admin_id );
-
- $created = $this->request(
- 'POST',
- self::BASE,
- array(
- 'extension_slug' => self::EXTENSION_SLUG,
- 'name' => 'Discounted monthly',
- 'billing_policy' => array(
- 'period' => 'month',
- 'interval' => 1,
- ),
- 'pricing_policy' => array(
- 'policies' => array(
- array(
- 'type' => 'percentage',
- 'value' => 10,
- ),
- ),
- 'one_time_fees' => array(
- array(
- 'kind' => 'setup',
- 'amount' => 5,
- ),
- ),
- 'custom_key' => 'kept',
- ),
- )
- );
- $this->assertSame( 201, $created->get_status() );
- $id = $this->int_value( $this->response_data( $created ), 'id' );
-
- $patched = $this->request(
- 'PATCH',
- self::BASE . '/' . $id,
- array(
- 'extension_slug' => self::EXTENSION_SLUG,
- 'pricing_policy' => array(
- 'policies' => array(
- array( 'type' => 'bogo' ),
- ),
- ),
- )
- );
- $this->assertSame( 200, $patched->get_status() );
-
- $fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
- $this->assertSame( 200, $fetched->get_status() );
- $this->assertSame(
- array(
- 'policies' => array(
- array( 'type' => 'bogo' ),
- ),
- 'one_time_fees' => array(
- array(
- 'kind' => 'setup',
- 'amount' => 5,
- ),
- ),
- 'custom_key' => 'kept',
- ),
- $this->response_data( $fetched )['pricing_policy']
- );
- }
-
public function test_arbitrary_payload_values_are_stored_as_given(): void {
wp_set_current_user( $this->admin_id );
@@ -319,7 +297,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
*
* @param mixed $invalid Non-object pricing payload.
* @param string $create_code Create error code: core schema validation rejects a scalar first.
- * @param string $patch_code PATCH error code: the route has no arg schema, so the controller rejects.
+ * @param string $patch_code PATCH error code: the route has no arg schema, so the facade rejects.
*/
public function test_non_object_pricing_policy_is_rejected( $invalid, string $create_code, string $patch_code ): void {
wp_set_current_user( $this->admin_id );
@@ -351,7 +329,12 @@ class PlansControllerTest extends EngineIntegrationTestCase {
)
);
$this->assertSame( 400, $patched->get_status() );
- $this->assertSame( $patch_code, $this->response_data( $patched )['code'] );
+ $patched_data = $this->response_data( $patched );
+ $this->assertSame( $patch_code, $patched_data['code'] );
+ $this->assertIsString( $patched_data['message'] );
+ if ( 'woocommerce_subscriptions_engine_invalid_plan' === $patch_code ) {
+ $this->assertStringContainsString( 'pricing_policy', $patched_data['message'], 'REST errors carry the facade message, which names the invalid field.' );
+ }
// The rejected writes left the plan untouched and created nothing.
$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
@@ -372,7 +355,27 @@ class PlansControllerTest extends EngineIntegrationTestCase {
);
}
- public function test_validate_action_receives_errors_a_plan_copy_and_slug(): void {
+ public function test_update_rejects_an_empty_name_with_a_message_naming_the_field(): void {
+ wp_set_current_user( $this->admin_id );
+ $id = $this->create_plan( 'Monthly' );
+
+ $patched = $this->request(
+ 'PATCH',
+ self::BASE . '/' . $id,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => '',
+ )
+ );
+
+ $this->assertSame( 400, $patched->get_status() );
+ $data = $this->response_data( $patched );
+ $this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $data['code'] );
+ $this->assertIsString( $data['message'] );
+ $this->assertStringContainsString( 'name', $data['message'] );
+ }
+
+ public function test_validate_action_receives_errors_a_plan_view_and_slug(): void {
wp_set_current_user( $this->admin_id );
$calls = array();
@@ -381,7 +384,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
static function ( $errors, $plan, $extension_slug ) use ( &$calls ): void {
self::assertInstanceOf( WP_Error::class, $errors );
self::assertFalse( $errors->has_errors() );
- self::assertInstanceOf( Plan::class, $plan );
+ self::assertInstanceOf( PlanView::class, $plan );
$calls[] = array( $plan->get_id(), $plan->get_name(), $plan->get_pricing_policy(), $extension_slug );
},
10,
@@ -428,16 +431,12 @@ class PlansControllerTest extends EngineIntegrationTestCase {
);
$this->assertSame( 200, $renamed->get_status() );
- $merged_pricing = array(
- 'policies' => array( array( 'type' => 'bogo' ) ),
- 'custom_key' => 'kept',
- 'one_time_fees' => array( array( 'amount' => 5 ) ),
- );
+ $replaced_pricing = array( 'one_time_fees' => array( array( 'amount' => 5 ) ) );
$this->assertSame(
array(
array(
- null,
+ 0,
'Validated',
array(
'policies' => array( array( 'type' => 'bogo' ) ),
@@ -445,18 +444,18 @@ class PlansControllerTest extends EngineIntegrationTestCase {
),
self::EXTENSION_SLUG,
),
- array( $id, 'Validated again', $merged_pricing, self::EXTENSION_SLUG ),
- array( $id, 'Renamed only', $merged_pricing, self::EXTENSION_SLUG ),
+ array( $id, 'Validated again', $replaced_pricing, self::EXTENSION_SLUG ),
+ array( $id, 'Renamed only', $replaced_pricing, self::EXTENSION_SLUG ),
),
$calls,
- 'Create passes a plan with a null id; updates pass the stored plan with the request merged in.'
+ 'Create passes a view with id 0; updates pass the would-be state with the request applied.'
);
}
/**
* @dataProvider provide_rejecting_errors
*
- * @param array<string, mixed> $data Error data the owner adds.
+ * @param array<string, mixed> $data Error data the extension adds.
* @param int $expected_status Expected response status.
*/
public function test_validate_action_error_rejects_create_and_update( array $data, int $expected_status ): void {
@@ -466,7 +465,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
add_action(
'woocommerce_subscriptions_engine_validate_plan',
static function ( WP_Error $errors ) use ( $data ): void {
- $errors->add( 'owner_rejected', 'No.', $data );
+ $errors->add( 'extension_rejected', 'No.', $data );
}
);
@@ -484,7 +483,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
)
);
$this->assertSame( $expected_status, $created->get_status() );
- $this->assertSame( 'owner_rejected', $this->response_data( $created )['code'] );
+ $this->assertSame( 'extension_rejected', $this->response_data( $created )['code'] );
$patched = $this->request(
'PATCH',
@@ -496,7 +495,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
)
);
$this->assertSame( $expected_status, $patched->get_status() );
- $this->assertSame( 'owner_rejected', $this->response_data( $patched )['code'] );
+ $this->assertSame( 'extension_rejected', $this->response_data( $patched )['code'] );
remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
@@ -563,14 +562,15 @@ class PlansControllerTest extends EngineIntegrationTestCase {
);
}
- public function test_validate_action_cannot_change_the_stored_plan(): void {
+ public function test_validate_action_receives_a_read_only_view_not_the_entity(): void {
wp_set_current_user( $this->admin_id );
+ $views = array();
add_action(
'woocommerce_subscriptions_engine_validate_plan',
- static function ( WP_Error $errors, Plan $plan ): void {
- $plan->set_name( 'Changed by callback' );
- $plan->set_pricing_policy( array( 'policies' => array( array( 'type' => 'changed' ) ) ) );
+ static function ( WP_Error $errors, $plan ) use ( &$views ): void {
+ unset( $errors );
+ $views[] = $plan;
},
10,
2
@@ -582,32 +582,16 @@ class PlansControllerTest extends EngineIntegrationTestCase {
array(
'extension_slug' => self::EXTENSION_SLUG,
'name' => 'As sent',
- 'billing_policy' => array(
- 'period' => 'month',
- 'interval' => 1,
- ),
'pricing_policy' => array( 'policies' => array( array( 'type' => 'raw' ) ) ),
)
);
$this->assertSame( 201, $created->get_status() );
$this->assertSame( 'As sent', $this->response_data( $created )['name'] );
- $id = $this->int_value( $this->response_data( $created ), 'id' );
-
- $patched = $this->request(
- 'PATCH',
- self::BASE . '/' . $id,
- array(
- 'extension_slug' => self::EXTENSION_SLUG,
- 'name' => 'As sent again',
- )
- );
- $this->assertSame( 200, $patched->get_status() );
-
- remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
- $fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
- $this->assertSame( 'As sent again', $fetched['name'] );
- $this->assertSame( array( 'policies' => array( array( 'type' => 'raw' ) ) ), $fetched['pricing_policy'] );
+ $this->assertCount( 1, $views );
+ $this->assertNotInstanceOf( Plan::class, $views[0], 'Callbacks never receive the mutable Core entity.' );
+ $this->assertInstanceOf( PlanView::class, $views[0] );
+ $this->assertSame( 'As sent', $views[0]->get_name() );
}
public function test_throwing_validate_callback_fails_the_write_without_storing(): void {
@@ -659,30 +643,21 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertSame( '1', $list->get_headers()['X-WP-Total'] );
}
- public function test_reorder_does_not_fire_the_validate_action(): void {
+ public function test_reorder_route_is_not_registered(): void {
wp_set_current_user( $this->admin_id );
- $first = $this->create_plan( 'First' );
- $second = $this->create_plan( 'Second' );
+ $id = $this->create_plan( 'First' );
- $calls = 0;
- add_action(
- 'woocommerce_subscriptions_engine_validate_plan',
- static function () use ( &$calls ): void {
- ++$calls;
- }
- );
+ $this->assertArrayNotHasKey( self::BASE . '/reorder', rest_get_server()->get_routes() );
- $reordered = $this->request(
+ $response = $this->request(
'POST',
self::BASE . '/reorder',
array(
'extension_slug' => self::EXTENSION_SLUG,
- 'ids' => array( $second, $first ),
+ 'ids' => array( $id ),
)
);
-
- $this->assertSame( 200, $reordered->get_status() );
- $this->assertSame( 0, $calls );
+ $this->assertSame( 404, $response->get_status() );
}
public function test_patch_with_null_pricing_policy_clears_the_payload(): void {
@@ -793,22 +768,55 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertSame( 'woocommerce-subscriptions-test', $second_data['extension_slug'] );
}
- public function test_list_can_order_by_status(): void {
+ public function test_list_defaults_to_id_order_and_rejects_retired_orderby_values(): 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' );
+ $charlie = $this->create_plan( 'Charlie' );
+ $alpha = $this->create_plan( 'Alpha' );
+
+ $list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+ $this->assertSame( array( $charlie, $alpha ), $this->response_ids( $list ) );
+
+ $by_name = $this->request(
+ 'GET',
+ self::BASE,
+ array(),
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'orderby' => 'name',
+ )
+ );
+ $this->assertSame( array( $alpha, $charlie ), $this->response_ids( $by_name ) );
- $archived_response = $this->request(
+ foreach ( array( 'status', 'sort_order' ) as $orderby ) {
+ $rejected = $this->request(
+ 'GET',
+ self::BASE,
+ array(),
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'orderby' => $orderby,
+ )
+ );
+ $this->assertSame( 400, $rejected->get_status(), "orderby={$orderby} must be rejected." );
+ }
+ }
+
+ public function test_status_filter_accepts_a_registered_extension_status_and_rejects_others(): void {
+ wp_set_current_user( $this->admin_id );
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
+ $this->create_plan( 'Active' );
+ $seasonal = $this->create_plan( 'Seasonal' );
+ $patched = $this->request(
'PATCH',
- self::BASE . '/' . $archived,
+ self::BASE . '/' . $seasonal,
array(
'extension_slug' => self::EXTENSION_SLUG,
- 'status' => Plan::STATUS_ARCHIVED,
+ 'status' => 'seasonal',
)
);
- $this->assertSame( 200, $archived_response->get_status() );
+ $this->assertSame( 'seasonal', $this->response_data( $patched )['status'] );
$list = $this->request(
'GET',
@@ -816,13 +824,32 @@ class PlansControllerTest extends EngineIntegrationTestCase {
array(),
array(
'extension_slug' => self::EXTENSION_SLUG,
- 'orderby' => 'status',
- 'order' => 'desc',
+ 'status' => 'seasonal',
)
);
-
$this->assertSame( 200, $list->get_status() );
- $this->assertSame( array( $archived, $active_before, $active_after ), $this->response_ids( $list ) );
+ $this->assertSame( array( $seasonal ), $this->response_ids( $list ) );
+
+ $unknown = $this->request(
+ 'GET',
+ self::BASE,
+ array(),
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => 'never-registered',
+ )
+ );
+ $this->assertSame( 400, $unknown->get_status() );
+
+ $bad_patch = $this->request(
+ 'PATCH',
+ self::BASE . '/' . $seasonal,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => 'never-registered',
+ )
+ );
+ $this->assertSame( 400, $bad_patch->get_status() );
}
public function test_single_plan_routes_reject_wildcard_and_list_extension_slugs(): void {
@@ -843,18 +870,38 @@ class PlansControllerTest extends EngineIntegrationTestCase {
)
)->get_status()
);
- $this->assertSame(
- 400,
- $this->request(
- 'POST',
- self::BASE . '/reorder',
- array(
- 'extension_slug' => $extension_slug,
- 'ids' => array( $id ),
- )
- )->get_status()
+ }
+ }
+
+ public function test_single_plan_routes_404_a_plan_of_another_extension_slug_or_an_unknown_id(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $foreign_id = $this->create_plan( 'Foreign', 'woocommerce-subscriptions-test' );
+ $unknown_id = $foreign_id + 1000;
+
+ foreach ( array( $foreign_id, $unknown_id ) as $id ) {
+ $get = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+ $this->assertSame( 404, $get->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_plan_not_found', $this->response_data( $get )['code'] );
+
+ $patch = $this->request(
+ 'PATCH',
+ self::BASE . '/' . $id,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Hijacked',
+ )
);
+ $this->assertSame( 404, $patch->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_plan_not_found', $this->response_data( $patch )['code'] );
}
+
+ $foreign = Plans::get( $foreign_id );
+ $this->assertNotNull( $foreign );
+ $this->assertSame( 'Foreign', $foreign->get_name(), 'A PATCH under another slug writes nothing.' );
+
+ $own = $this->request( 'GET', self::BASE . '/' . $foreign_id, array(), array( 'extension_slug' => 'woocommerce-subscriptions-test' ) );
+ $this->assertSame( 200, $own->get_status(), 'The plan resolves under its own slug.' );
}
public function test_create_rejects_wildcard_and_list_extension_slugs(): void {
@@ -878,6 +925,72 @@ class PlansControllerTest extends EngineIntegrationTestCase {
}
}
+ public function test_create_validates_the_status_and_keeps_the_callback_out_of_the_schema(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Unregistered',
+ 'status' => 'never-registered',
+ )
+ );
+ $this->assertSame( 400, $created->get_status() );
+ $this->assertSame( 'rest_invalid_param', $this->response_data( $created )['code'] );
+
+ $schema = ( new PlansController() )->get_public_item_schema();
+ $this->assertIsArray( $schema['properties']['status'] );
+ $this->assertArrayNotHasKey( 'validate_callback', $schema['properties']['status'] );
+ $this->assertArrayNotHasKey( 'arg_options', $schema['properties']['status'] );
+ }
+
+ public function test_create_surfaces_a_failed_insert_as_a_logged_create_error(): void {
+ global $wpdb;
+
+ wp_set_current_user( $this->admin_id );
+
+ $break_plan_inserts = static function ( $query ) {
+ if ( is_string( $query ) && 0 === stripos( ltrim( $query ), 'INSERT' ) && false !== strpos( $query, 'wc_selling_plans' ) ) {
+ return 'INSERT INTO nonexistent_table_for_this_test (id) VALUES (1)';
+ }
+
+ return $query;
+ };
+ $errors = array();
+ $capture = static function ( $message, $level, $context ) use ( &$errors ) {
+ if ( 'error' === $level && is_string( $message ) && is_array( $context ) && 'woocommerce-subscriptions-engine' === ( $context['source'] ?? null ) ) {
+ $errors[] = $message;
+ }
+ return $message;
+ };
+ add_filter( 'query', $break_plan_inserts );
+ add_filter( 'woocommerce_logger_log_message', $capture, 10, 3 );
+ $suppressed = $wpdb->suppress_errors( true );
+
+ try {
+ $response = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Doomed insert',
+ )
+ );
+ } finally {
+ $wpdb->suppress_errors( $suppressed );
+ remove_filter( 'query', $break_plan_inserts );
+ remove_filter( 'woocommerce_logger_log_message', $capture, 10 );
+ }
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_plan_create_failed', $this->response_data( $response )['code'] );
+ $this->assertStringNotContainsString( 'nonexistent_table_for_this_test', (string) wp_json_encode( $response->get_data() ), 'The database error never reaches the client.' );
+ $this->assertNotEmpty( $errors, 'A failed write is logged.' );
+ $this->assertStringContainsString( 'nonexistent_table_for_this_test', $errors[0], 'The log carries the database error.' );
+ }
+
public function test_update_surfaces_a_failed_write_as_an_error(): void {
global $wpdb;
@@ -912,69 +1025,33 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertSame( 500, $response->get_status() );
$this->assertSame( 'woocommerce_subscriptions_engine_plan_update_failed', $this->response_data( $response )['code'] );
+ $this->assertStringNotContainsString( 'nonexistent_table_for_this_test', (string) wp_json_encode( $response->get_data() ), 'The database error never reaches the client.' );
}
- public function test_archive_restore_and_reorder(): void {
+ public function test_archive_and_restore(): void {
wp_set_current_user( $this->admin_id );
- $first = $this->create_plan( 'First' );
- $second = $this->create_plan( 'Second' );
+ $first = $this->create_plan( 'First' );
$archived = $this->request(
'PATCH',
self::BASE . '/' . $first,
array(
'extension_slug' => self::EXTENSION_SLUG,
- 'status' => Plan::STATUS_ARCHIVED,
+ 'status' => PlanStatus::ARCHIVED,
)
);
- $this->assertSame( Plan::STATUS_ARCHIVED, $this->response_data( $archived )['status'] );
+ $this->assertSame( PlanStatus::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 ),
+ 'status' => PlanStatus::ACTIVE,
)
);
-
- $this->assertSame( 400, $reordered->get_status() );
+ $this->assertSame( PlanStatus::ACTIVE, $this->response_data( $restored )['status'] );
}
public function test_delete_route_is_not_exposed(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php
deleted file mode 100644
index da400c7c9a8..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SellingPlansTest.php
+++ /dev/null
@@ -1,125 +0,0 @@
-<?php
-/**
- * Integration tests for the SellingPlans facade.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
-
-use EngineIntegrationTestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\SellingPlans;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\SellingPlans
- */
-class SellingPlansTest extends EngineIntegrationTestCase {
-
- private const SLUG = 'lite';
-
- /**
- * Insert a plan.
- *
- * @param string $name Plan name.
- * @param array<string, mixed> $overrides Attribute overrides.
- */
- private function make_plan( string $name, array $overrides = array() ): int {
- return ( new PlanRepository() )->insert(
- Plan::create(
- array_merge(
- array(
- 'name' => $name,
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- ),
- 'extension_slug' => self::SLUG,
- ),
- $overrides
- )
- )
- );
- }
-
- /**
- * Map plans to their ids.
- *
- * @param array<int, Plan> $plans Plans to map.
- * @return array<int, int|null>
- */
- private static function plan_ids( array $plans ): array {
- return array_map(
- static function ( Plan $plan ): ?int {
- return $plan->get_id();
- },
- $plans
- );
- }
-
- public function test_list_plans_excludes_archived_and_foreign_slug_plans(): void {
- $second_id = $this->make_plan( 'Second', array( 'sort_order' => 2 ) );
- $first_id = $this->make_plan( 'First', array( 'sort_order' => 1 ) );
- $this->make_plan( 'Archived', array( 'status' => Plan::STATUS_ARCHIVED ) );
- $this->make_plan( 'Foreign', array( 'extension_slug' => 'other-extension' ) );
-
- $plans = ( new SellingPlans( array( self::SLUG ) ) )->list_plans();
-
- $this->assertSame( array( $first_id, $second_id ), self::plan_ids( $plans ) );
- }
-
- public function test_get_plans_returns_active_owned_plans_in_display_order(): void {
- $second_id = $this->make_plan( 'Second', array( 'sort_order' => 2 ) );
- $first_id = $this->make_plan( 'First', array( 'sort_order' => 1 ) );
- $excluded_id = $this->make_plan( 'Excluded', array( 'sort_order' => 3 ) );
- $archived_id = $this->make_plan( 'Archived', array( 'status' => Plan::STATUS_ARCHIVED ) );
- $foreign_id = $this->make_plan( 'Foreign', array( 'extension_slug' => 'other-extension' ) );
-
- $catalog = new SellingPlans( array( self::SLUG ) );
-
- $plans = $catalog->get_plans( array( $second_id, $first_id, $archived_id, $foreign_id, 999999 ) );
-
- // Display order regardless of request order; archived, foreign, and unknown ids are absent.
- $this->assertSame( array( $first_id, $second_id ), self::plan_ids( $plans ) );
- $this->assertNotContains( $excluded_id, self::plan_ids( $plans ) );
- }
-
- public function test_get_plans_empty_or_invalid_ids_yield_an_empty_array(): void {
- $plan_id = $this->make_plan( 'Plan' );
-
- $catalog = new SellingPlans( array( self::SLUG ) );
-
- // Non-int junk coverage lives in PlanRepositoryTest; the facade takes int ids.
- $this->assertSame( array(), $catalog->get_plans( array() ) );
- $this->assertSame( array(), $catalog->get_plans( array( $plan_id, 0 ) ) );
- }
-
- public function test_two_slug_instance_reads_across_both_slugs(): void {
- $lite_id = $this->make_plan( 'Lite plan', array( 'sort_order' => 1 ) );
- $other_id = $this->make_plan(
- 'Other plan',
- array(
- 'sort_order' => 2,
- 'extension_slug' => 'other-extension',
- )
- );
- $foreign_id = $this->make_plan(
- 'Foreign',
- array(
- 'sort_order' => 3,
- 'extension_slug' => 'third-extension',
- )
- );
-
- $catalog = new SellingPlans( array( self::SLUG, 'other-extension' ) );
-
- $this->assertSame( array( $lite_id, $other_id ), self::plan_ids( $catalog->list_plans() ) );
- $this->assertSame( array( $lite_id, $other_id ), self::plan_ids( $catalog->get_plans( array( $other_id, $lite_id, $foreign_id ) ) ) );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
index 112b9b1d09a..8885475dacd 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -17,12 +17,9 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions
@@ -54,15 +51,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
* @return Contract The persisted contract with cycle 1 billed.
*/
private function sign_up_contract( int $customer_id = 0 ): Contract {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'engine-tests',
- )
- );
- ( new PlanRepository() )->insert( $plan );
+ $plan = $this->plan_view( $this->make_plan() );
$order = new WC_Order();
$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index ff963ed45d5..8efba72e1a6 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -11,9 +11,11 @@
declare( strict_types=1 );
use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
/**
@@ -21,6 +23,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
*/
abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
+ /**
+ * Owner slug of the plans {@see self::make_plan()} creates by default.
+ */
+ protected const PLAN_OWNER = 'engine-tests';
+
/**
* Gateway ids wired with an approving scheduled-payment handler, to unhook on teardown.
*
@@ -89,6 +96,79 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
$this->approved_gateways[] = $gateway;
}
+ /**
+ * Create a plan through the plan facade: a monthly billing payload owned by
+ * {@see self::PLAN_OWNER} unless overridden.
+ *
+ * @param array<string, mixed> $overrides `Plans::create()` args to replace.
+ * @return int The plan id.
+ */
+ protected function make_plan( array $overrides = array() ): int {
+ $plan = Plans::create(
+ array_merge(
+ array(
+ 'extension_slug' => self::PLAN_OWNER,
+ 'name' => 'Monthly',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ ),
+ $overrides
+ )
+ );
+
+ return $plan->get_id();
+ }
+
+ /**
+ * Run `$run` and return the context of every engine log entry at `$level` it wrote whose
+ * context matches `$context_match` (for example a contract id). Matching on source, level and
+ * context, not message text, keeps the assertions valid when a message is reworded.
+ *
+ * @param string $level Log level, e.g. `warning`.
+ * @param array<string, mixed> $context_match Context keys and values every returned entry carries.
+ * @param callable $run Code under test.
+ * @return array<int, array<string, mixed>> Contexts of the matching entries, in log order.
+ */
+ protected function capture_engine_log( string $level, array $context_match, callable $run ): array {
+ $entries = array();
+ $capture = static function ( $message, $entry_level, $context ) use ( &$entries, $level, $context_match ) {
+ if ( $level !== $entry_level || ! is_array( $context ) || 'woocommerce-subscriptions-engine' !== ( $context['source'] ?? null ) ) {
+ return $message;
+ }
+ foreach ( $context_match as $key => $value ) {
+ if ( ! array_key_exists( $key, $context ) || $value !== $context[ $key ] ) {
+ return $message;
+ }
+ }
+ $entries[] = $context;
+
+ return $message;
+ };
+ add_filter( 'woocommerce_logger_log_message', $capture, 10, 3 );
+
+ try {
+ $run();
+ } finally {
+ remove_filter( 'woocommerce_logger_log_message', $capture, 10 );
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Read a plan through the plan facade, asserting it exists.
+ *
+ * @param int $plan_id Plan id.
+ */
+ protected function plan_view( int $plan_id ): PlanView {
+ $plan = Plans::get( $plan_id );
+ $this->assertInstanceOf( PlanView::class, $plan );
+
+ return $plan;
+ }
+
/**
* Sign up a contract for a paid order on `$plan` through the contracts facade, the way an
* extension maps its checkout: create a draft from explicit order fields and snapshots,
@@ -96,11 +176,11 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
* customer gets a new one.
*
* @param WC_Order $order Saved, paid order.
- * @param Plan $plan Saved selling plan.
+ * @param PlanView $plan Saved selling plan.
* @param array<string, mixed> $overrides `Contracts::create()` fields to replace; `status` is the final status.
* @return int The contract id.
*/
- protected function sign_up_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): int {
+ protected function sign_up_from_order( WC_Order $order, PlanView $plan, array $overrides = array() ): int {
if ( $order->get_customer_id() <= 0 ) {
$customer_id = self::factory()->user->create();
$this->assertIsInt( $customer_id );
@@ -143,7 +223,7 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
'payment_method_title' => '' !== $order->get_payment_method_title() ? $order->get_payment_method_title() : null,
'payment_token_id' => $token_id > 0 ? $token_id : null,
'start_gmt' => $start,
- 'next_payment_gmt' => $plan->get_billing_policy()->compute_first_renewal_from( $start ),
+ 'next_payment_gmt' => BillingPolicy::from_array( $plan->get_billing_policy() ?? array() )->compute_first_renewal_from( $start ),
'billing_total' => (string) $order->get_total(),
'discount_total' => (string) $order->get_total_discount(),
'shipping_total' => (string) $order->get_shipping_total(),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
index e3eabcbedfb..a1fccbae5c2 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
@@ -17,13 +17,10 @@ use DomainException;
use EngineIntegrationTestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
@@ -59,26 +56,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
* Create a monthly plan and return its id.
*/
private function make_monthly_plan(): int {
- return $this->make_plan( 'month' );
- }
-
- /**
- * Create a plan on the given cadence period and return its id.
- *
- * @param string $period Billing period slug: day/week/month/year.
- */
- private function make_plan( string $period ): int {
- $plan = Plan::create(
- array(
- 'name' => ucfirst( $period ) . 'ly',
- 'billing_policy' => new BillingPolicy( $period, 1, null, null, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'engine-tests',
- )
- );
- ( new PlanRepository() )->insert( $plan );
-
- return (int) $plan->get_id();
+ return $this->make_plan();
}
/**
@@ -227,7 +205,17 @@ class ReactivationTest extends EngineIntegrationTestCase {
public function test_reactivate_floors_past_due_at_now_when_the_roll_cap_exhausts(): void {
// Daily cadence, held ~6.5 years past due: more rolls than the cap allows, so
// the date is floored at `$now` - never returned still in the past.
- $id = $this->seed_on_hold( '2020-01-01 00:00:00', $this->make_plan( 'day' ) );
+ $id = $this->seed_on_hold(
+ '2020-01-01 00:00:00',
+ $this->make_plan(
+ array(
+ 'billing_policy' => array(
+ 'period' => 'day',
+ 'interval' => 1,
+ ),
+ )
+ )
+ );
$this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-07-06 00:00:00' ) );
@@ -243,6 +231,116 @@ class ReactivationTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-04-15 09:30:00', $this->reload( $id )->get_next_payment_gmt() );
}
+ /**
+ * @dataProvider provide_unusable_live_billing_payloads
+ *
+ * @param array<string, mixed>|null $billing The live plan's billing payload.
+ */
+ public function test_reactivate_floors_past_due_at_now_when_the_live_billing_is_unusable( ?array $billing ): void {
+ $plan_id = $this->make_plan( array( 'billing_policy' => $billing ) );
+ $id = $this->seed_on_hold( '2026-02-01 00:00:00', $plan_id );
+
+ $warnings = $this->capture_engine_log(
+ 'warning',
+ array(
+ 'contract_id' => $id,
+ 'plan_id' => $plan_id,
+ ),
+ function () use ( $id ): void {
+ $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 09:30:00' ) );
+ }
+ );
+
+ $this->assertSame( '2026-04-15 09:30:00', $this->reload( $id )->get_next_payment_gmt() );
+ $this->assertNotEmpty( $warnings, 'A null or unusable live billing payload is logged with the contract and plan.' );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>|null}>
+ */
+ public function provide_unusable_live_billing_payloads(): array {
+ return array(
+ 'null payload' => array( null ),
+ 'missing interval' => array( array( 'period' => 'month' ) ),
+ 'unknown period' => array(
+ array(
+ 'period' => 'decade',
+ 'interval' => 1,
+ ),
+ ),
+ 'zero interval' => array(
+ array(
+ 'period' => 'month',
+ 'interval' => 0,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * A snapshot policy with no usable cadence falls through to the live plan (logged),
+ * the same as renewal, instead of throwing out of the forward roll.
+ *
+ * @dataProvider provide_unusable_snapshot_billing_payloads
+ *
+ * @param array<string, mixed> $snapshot_billing The snapshot's billing payload.
+ * @param array<string, mixed>|null $live_billing The live plan's billing payload.
+ * @param string $expected_next The expected next payment.
+ */
+ public function test_reactivate_falls_back_to_the_live_plan_when_the_snapshot_billing_is_unusable( array $snapshot_billing, ?array $live_billing, string $expected_next ): void {
+ $id = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_plan( array( 'billing_policy' => $live_billing ) ) );
+
+ $contract = $this->reload( $id );
+ $contract->set_plan_snapshot(
+ PlanSnapshot::from_array(
+ array(
+ 'selling_plan_id' => $contract->get_selling_plan_id(),
+ 'billing_policy' => $snapshot_billing,
+ )
+ )
+ );
+
+ $warnings = $this->capture_engine_log(
+ 'warning',
+ array( 'contract_id' => $id ),
+ function () use ( $contract ): void {
+ $this->assertTrue( $this->sut->reactivate( $contract, $this->utc( '2026-04-15 09:30:00' ) ) );
+ }
+ );
+
+ $stored = $this->reload( $id );
+ $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+ $this->assertSame( $expected_next, $stored->get_next_payment_gmt() );
+ $this->assertNotEmpty( $warnings, 'An unusable snapshot billing payload is logged.' );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>|null, 2: string}>
+ */
+ public function provide_unusable_snapshot_billing_payloads(): array {
+ $monthly = array(
+ 'period' => 'month',
+ 'interval' => 1,
+ );
+ $decade = array(
+ 'period' => 'decade',
+ 'interval' => 1,
+ );
+ $zero = array(
+ 'period' => 'month',
+ 'interval' => 0,
+ );
+ $rolled = '2026-05-01 00:00:00';
+ $floored = '2026-04-15 09:30:00';
+
+ return array(
+ 'unknown period, live monthly' => array( $decade, $monthly, $rolled ),
+ 'zero interval, live monthly' => array( $zero, $monthly, $rolled ),
+ 'unknown period, live unusable' => array( $decade, $zero, $floored ),
+ 'zero interval, live null payload' => array( $zero, null, $floored ),
+ );
+ }
+
public function test_reactivate_leaves_a_null_next_payment_null(): void {
$id = $this->seed_on_hold( null, $this->make_monthly_plan() );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
index 6f66130bd5d..c0e16f372f5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -23,17 +23,14 @@ use EngineIntegrationTestCase;
use WC_Order;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
@@ -153,15 +150,7 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
* @param string $owner The plan's (and so the contract's) extension slug.
*/
private function sign_up( string $owner ): int {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => $owner,
- )
- );
- ( new PlanRepository() )->insert( $plan );
+ $plan = $this->plan_view( $this->make_plan( array( 'extension_slug' => $owner ) ) );
$order = new WC_Order();
$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
index c39089d3004..726e17b883d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -18,14 +18,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher
@@ -65,23 +62,6 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
return new DateTimeImmutable( '2026-02-15 00:00:00', new DateTimeZone( 'UTC' ) );
}
- /**
- * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
- */
- private function make_plan_object(): Plan {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'engine-tests',
- )
- );
- ( new PlanRepository() )->insert( $plan );
-
- return $plan;
- }
-
/**
* Sign up a contract through the contracts facade so its billing chain holds cycle 1 (billed),
* with its next payment due at the given date.
@@ -91,7 +71,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
* @return Contract The persisted contract with cycle 1 billed.
*/
private function sign_up_contract( string $gateway, string $next_payment_gmt ): Contract {
- $plan = $this->make_plan_object();
+ $plan = $this->plan_view( $this->make_plan() );
$order = new WC_Order();
$order->set_currency( 'USD' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
index 2ac1e894e24..f9990ab4f58 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
@@ -16,9 +16,10 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
@@ -28,6 +29,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
@@ -105,27 +107,23 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
return $order instanceof WC_Order ? $order : null;
}
- private function make_plan( ?int $max_cycles = null ): int {
- return (int) $this->make_plan_object( $max_cycles )->get_id();
- }
-
/**
- * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
+ * Create a monthly plan and return its view (the sign-up helper needs the plan).
*
* @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
*/
- private function make_plan_object( ?int $max_cycles = null ): Plan {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, $max_cycles, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'engine-tests',
+ private function make_plan_view( ?int $max_cycles = null ): PlanView {
+ return $this->plan_view(
+ $this->make_plan(
+ array(
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'max_cycles' => $max_cycles,
+ ),
+ )
)
);
- ( new PlanRepository() )->insert( $plan );
-
- return $plan;
}
/**
@@ -137,7 +135,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
* @return Contract The persisted contract with cycle 1 billed.
*/
private function sign_up_contract( string $gateway, ?int $max_cycles = null ): Contract {
- $plan = $this->make_plan_object( $max_cycles );
+ $plan = $this->make_plan_view( $max_cycles );
$order = new WC_Order();
$order->set_currency( 'USD' );
@@ -447,7 +445,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order->add_item( $line );
$order->save();
- $contract_id = $this->sign_up_from_order( $order, $this->make_plan_object() );
+ $contract_id = $this->sign_up_from_order( $order, $this->make_plan_view() );
$renewal_order = $this->run_scheduled_renewal( $contract_id );
$this->assertInstanceOf( WC_Order::class, $renewal_order );
@@ -653,6 +651,175 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertNull( $reloaded->get_next_payment_gmt() );
}
+ /**
+ * @testdox the scheduled scan parks a contract without a snapshot whose live plan billing payload is null, does not parse, or has no usable cadence.
+ *
+ * @dataProvider provide_unusable_live_billing_payloads
+ *
+ * @param array<string, mixed>|null $billing The live plan's billing payload.
+ */
+ public function test_scheduled_renewal_parks_a_contract_whose_live_billing_is_unusable( ?array $billing ): void {
+ GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+ $plan_id = $this->make_plan();
+ $order = $this->make_origin_order();
+ $contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $repo = new ContractRepository();
+ $repo->append_cycle(
+ Cycle::create(
+ array(
+ 'contract_id' => $contract_id,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'status' => new CycleStatus( CycleStatus::BILLED ),
+ 'starts_at_gmt' => '2026-01-15 00:00:00',
+ 'ends_at_gmt' => '2026-02-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ )
+ )
+ );
+
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $plan_id,
+ array(
+ 'extension_slug' => self::PLAN_OWNER,
+ 'billing_policy' => $billing,
+ )
+ )
+ );
+
+ $result = null;
+ $warnings = $this->capture_engine_log(
+ 'warning',
+ array(
+ 'contract_id' => $contract_id,
+ 'plan_id' => $plan_id,
+ ),
+ function () use ( &$result, $contract_id ): void {
+ $result = $this->run_scheduled_renewal( $contract_id );
+ }
+ );
+
+ $this->assertNull( $result );
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertNull( $reloaded->get_next_payment_gmt(), 'The contract is parked out of the due set.' );
+ $this->assertNotEmpty( $warnings, 'A null or unusable live billing payload is logged with the contract and plan.' );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>|null}>
+ */
+ public function provide_unusable_live_billing_payloads(): array {
+ return array(
+ 'null payload' => array( null ),
+ 'missing interval' => array( array( 'period' => 'month' ) ),
+ 'unknown period' => array(
+ array(
+ 'period' => 'decade',
+ 'interval' => 1,
+ ),
+ ),
+ 'zero interval' => array(
+ array(
+ 'period' => 'month',
+ 'interval' => 0,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox the scheduled scan falls back to the live plan when the snapshot billing has no usable cadence, and parks when neither is usable.
+ *
+ * @dataProvider provide_unusable_snapshot_billing_payloads
+ *
+ * @param array<string, mixed> $snapshot_billing The snapshot's billing payload.
+ * @param array<string, mixed>|null $live_billing The live plan's billing payload.
+ * @param string|null $expected_next The next payment after the run; null when parked.
+ */
+ public function test_scheduled_renewal_reads_an_unusable_snapshot_billing_through_the_live_plan( array $snapshot_billing, ?array $live_billing, ?string $expected_next ): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $plan = $this->make_plan_view();
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( self::GATEWAY_APPROVING );
+ $order->set_total( '19.99' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ $order->save();
+
+ $contract_id = $this->sign_up_from_order( $order, $plan );
+ $this->seed_plan_snapshot(
+ $contract_id,
+ array(
+ 'selling_plan_id' => $plan->get_id(),
+ 'name' => $plan->get_name(),
+ 'billing_policy' => $snapshot_billing,
+ )
+ );
+ $this->assertInstanceOf(
+ PlanView::class,
+ Plans::update(
+ $plan->get_id(),
+ array(
+ 'extension_slug' => self::PLAN_OWNER,
+ 'billing_policy' => $live_billing,
+ )
+ )
+ );
+
+ $result = null;
+ $warnings = $this->capture_engine_log(
+ 'warning',
+ array( 'contract_id' => $contract_id ),
+ function () use ( &$result, $contract_id ): void {
+ $result = $this->run_scheduled_renewal( $contract_id );
+ }
+ );
+
+ $this->assertNotEmpty( $warnings, 'An unusable snapshot billing payload is logged.' );
+ $this->assertSame( $expected_next, $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+ if ( null === $expected_next ) {
+ $this->assertNull( $result );
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ), 'A parked contract bills nothing.' );
+ } else {
+ $this->assertInstanceOf( WC_Order::class, $result, 'The renewal bills on the live cadence.' );
+ }
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>|null, 2: string|null}>
+ */
+ public function provide_unusable_snapshot_billing_payloads(): array {
+ $monthly = array(
+ 'period' => 'month',
+ 'interval' => 1,
+ );
+ $decade = array(
+ 'period' => 'decade',
+ 'interval' => 1,
+ );
+ $zero = array(
+ 'period' => 'month',
+ 'interval' => 0,
+ );
+
+ return array(
+ 'unknown period, live monthly' => array( $decade, $monthly, '2026-03-15 00:00:00' ),
+ 'zero interval, live monthly' => array( $zero, $monthly, '2026-03-15 00:00:00' ),
+ 'unknown period, live unusable' => array( $decade, $zero, null ),
+ 'zero interval, live null' => array( $zero, null, null ),
+ );
+ }
+
/**
* @testdox the scheduled scan resumes a stalled renewal whose order was saved but never charged.
*
@@ -851,6 +1018,21 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => $status ), array( 'id' => $contract_id ) );
}
+ /**
+ * Store a plan snapshot row and point the contract at it.
+ *
+ * @param int $contract_id Stored contract id.
+ * @param array<string, mixed> $payload Plan snapshot payload.
+ */
+ private function seed_plan_snapshot( int $contract_id, array $payload ): void {
+ $contract = $this->reload_contract( $contract_id );
+ $snapshot = PlanSnapshot::from_array( $payload );
+ $contract->set_plan_snapshot_id(
+ ( new SnapshotStore() )->insert( $contract_id, SnapshotStore::TYPE_PLAN, $snapshot->get_selling_plan_id(), $snapshot->to_payload(), $snapshot->get_schema_version() )
+ );
+ ( new ContractRepository() )->update_fields( $contract, array( 'plan_snapshot_id' ) );
+ }
+
/**
* Reload a contract, asserting it still exists.
*
@@ -1802,16 +1984,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
* @return Contract The persisted contract with cycle 1 billed.
*/
private function sign_up_contract_with_line_item( string $gateway, ?array $pricing_policy, $quantity = 2 ): Contract {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'pricing_policy' => $pricing_policy,
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'engine-tests',
- )
- );
- ( new PlanRepository() )->insert( $plan );
+ $plan = $this->plan_view( $this->make_plan( array( 'pricing_policy' => $pricing_policy ) ) );
$product = new \WC_Product_Simple();
$product->set_name( 'Monthly Filters' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanMetaTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanMetaTest.php
new file mode 100644
index 00000000000..deb113d6ae2
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanMetaTest.php
@@ -0,0 +1,345 @@
+<?php
+/**
+ * Integration tests for the multi-value plan meta reads and writes on
+ * PlanRepository (WordPress post-meta semantics).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository
+ */
+class PlanMetaTest extends EngineIntegrationTestCase {
+
+ /**
+ * The System Under Test.
+ *
+ * @var PlanRepository
+ */
+ private $sut;
+
+ /**
+ * A stored plan id.
+ *
+ * @var int
+ */
+ private $id;
+
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new PlanRepository();
+ $this->id = $this->sut->insert(
+ Plan::create(
+ array(
+ 'name' => 'Meta plan',
+ 'extension_slug' => 'acme-subs',
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox add_meta keeps every value under one key in order.
+ */
+ public function test_add_meta_keeps_every_value_under_one_key_in_order(): void {
+ $first = $this->sut->add_meta( $this->id, 'note', 'one' );
+ $second = $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertIsInt( $first );
+ $this->assertIsInt( $second );
+ $this->assertGreaterThan( $first, $second );
+ $this->assertSame( array( 'one', 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+ $this->assertSame( 'one', $this->sut->get_meta( $this->id, 'note', true ) );
+ }
+
+ /**
+ * @testdox unique add on an existing key adds nothing.
+ */
+ public function test_unique_add_on_an_existing_key_adds_nothing(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+
+ $this->assertNull( $this->sut->add_meta( $this->id, 'note', 'two', true ) );
+ $this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox update_meta adds the key when absent.
+ */
+ public function test_update_meta_adds_when_absent(): void {
+ $this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+ $this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox update_meta rewrites every row for the key.
+ */
+ public function test_update_meta_rewrites_every_row_for_the_key(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three' ) );
+ $this->assertSame( array( 'three', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox update_meta with a previous value rewrites only matching rows.
+ */
+ public function test_update_meta_with_a_previous_value_rewrites_only_matching_rows(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three', 'two' ) );
+ $this->assertSame( array( 'one', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+ $this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'four', 'missing' ) );
+ }
+
+ /**
+ * @testdox update_meta to the same value reports no change.
+ */
+ public function test_update_meta_to_the_same_value_reports_no_change(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+
+ $this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+ $this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox delete_meta with a value removes only that row.
+ */
+ public function test_delete_meta_with_a_value_removes_only_that_row(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertTrue( $this->sut->delete_meta( $this->id, 'note', 'one' ) );
+ $this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox delete_meta without a value removes every row for the key.
+ */
+ public function test_delete_meta_without_a_value_removes_every_row_for_the_key(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+ $this->sut->add_meta( $this->id, 'other', 'kept' );
+
+ $this->assertTrue( $this->sut->delete_meta( $this->id, 'note' ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+ $this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'other' ) );
+ $this->assertFalse( $this->sut->delete_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox get_meta without a key groups every key.
+ */
+ public function test_get_meta_without_a_key_groups_every_key(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'flag', 'yes' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertSame(
+ array(
+ 'note' => array( 'one', 'two' ),
+ 'flag' => array( 'yes' ),
+ ),
+ $this->sut->get_meta( $this->id )
+ );
+ }
+
+ /**
+ * @testdox arrays round-trip through serialization.
+ */
+ public function test_arrays_round_trip_through_serialization(): void {
+ $value = array(
+ 'a' => 1,
+ 'b' => array( 'c' ),
+ );
+
+ $this->sut->add_meta( $this->id, 'payload', $value );
+
+ $this->assertSame( $value, $this->sut->get_meta( $this->id, 'payload', true ) );
+ $this->assertTrue( $this->sut->delete_meta( $this->id, 'payload', $value ) );
+ }
+
+ /**
+ * @testdox a missing key reads as empty.
+ */
+ public function test_missing_key_reads_as_empty(): void {
+ $this->assertSame( '', $this->sut->get_meta( $this->id, 'missing', true ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id, 'missing' ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id ) );
+ }
+
+ /**
+ * @testdox meta is scoped to its plan.
+ */
+ public function test_meta_is_scoped_to_its_plan(): void {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Other',
+ 'extension_slug' => 'acme-subs',
+ )
+ );
+ $other = $this->sut->insert( $plan );
+ $this->sut->add_meta( $other, 'note', 'theirs' );
+
+ $this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox meta survives a plan update.
+ */
+ public function test_meta_survives_a_plan_update(): void {
+ $this->sut->add_meta( $this->id, 'note', 'kept' );
+
+ $plan = $this->sut->find( $this->id );
+ $this->assertInstanceOf( Plan::class, $plan );
+ $plan->set_name( 'Renamed' );
+ $this->sut->update_fields( $plan, array( 'name' ) );
+
+ $this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox delete removes the plan's meta.
+ */
+ public function test_delete_removes_the_plan_meta(): void {
+ $this->sut->add_meta( $this->id, 'note', 'gone' );
+
+ $this->assertTrue( $this->sut->delete( $this->id ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id ) );
+ }
+
+ /**
+ * @testdox delete throws when the meta rows fail to delete, so a caller transaction can roll back.
+ */
+ public function test_delete_throws_when_the_meta_delete_fails(): void {
+ $this->assert_write_throws_on_a_failed_query(
+ 'DELETE FROM `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ) . '`',
+ 'Failed to delete meta rows',
+ function (): void {
+ $this->sut->delete( $this->id );
+ }
+ );
+ }
+
+ /**
+ * @testdox delete throws when the plan row fails to delete, and keeps the plan and its meta.
+ */
+ public function test_delete_throws_when_the_plan_row_delete_fails(): void {
+ $this->sut->add_meta( $this->id, 'note', 'kept' );
+
+ $this->assert_write_throws_on_a_failed_query(
+ 'DELETE FROM `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS ) . '`',
+ 'Failed to delete plan ' . $this->id,
+ function (): void {
+ $this->sut->delete( $this->id );
+ }
+ );
+
+ $this->assertNotNull( $this->sut->find( $this->id ) );
+ $this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox a failed meta write throws.
+ * @dataProvider provide_failing_meta_writes
+ *
+ * @param string $statement Start of the failing SQL statement.
+ * @param string $message Expected message fragment.
+ * @param string $method Repository method.
+ * @param array<int, mixed> $args Arguments after the plan id.
+ */
+ public function test_a_failed_meta_write_throws( string $statement, string $message, string $method, array $args ): void {
+ $this->sut->add_meta( $this->id, 'existing', 'one' );
+
+ $this->assert_write_throws_on_a_failed_query(
+ $statement . ' `' . SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META ) . '`',
+ $message,
+ function () use ( $method, $args ): void {
+ $this->sut->{$method}( $this->id, ...$args );
+ }
+ );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: string, 2: string, 3: array<int, mixed>}>
+ */
+ public function provide_failing_meta_writes(): array {
+ return array(
+ 'add' => array( 'INSERT INTO', 'Failed to add plan meta', 'add_meta', array( 'note', 'x' ) ),
+ 'update' => array( 'UPDATE', 'Failed to update plan meta', 'update_meta', array( 'existing', 'two' ) ),
+ 'delete' => array( 'DELETE FROM', 'Failed to delete plan meta', 'delete_meta', array( 'existing' ) ),
+ );
+ }
+
+ /**
+ * Break the queries starting with `$statement` and assert `$write` throws.
+ *
+ * @param string $statement Start of the SQL statement to break.
+ * @param string $message Expected message fragment.
+ * @param callable $write Write to run.
+ */
+ private function assert_write_throws_on_a_failed_query( string $statement, string $message, callable $write ): void {
+ global $wpdb;
+
+ $break = static function ( string $query ) use ( $statement ): string {
+ return 0 === strpos( $query, $statement ) ? 'SELECT broken syntax (' : $query;
+ };
+ add_filter( 'query', $break );
+ $suppressed = $wpdb->suppress_errors( true );
+
+ try {
+ $write();
+ $this->fail( 'Expected the write to throw.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( $message, $e->getMessage() );
+ } finally {
+ $wpdb->suppress_errors( $suppressed );
+ remove_filter( 'query', $break );
+ }
+ }
+
+ /**
+ * @testdox a delete under another extension slug keeps the plan and its meta.
+ */
+ public function test_a_delete_under_another_extension_slug_keeps_the_plan_and_its_meta(): void {
+ $this->sut->add_meta( $this->id, 'note', 'kept' );
+
+ $this->assertFalse( $this->sut->delete( $this->id, 'another-extension' ) );
+ $this->assertNotNull( $this->sut->find( $this->id ) );
+ $this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @testdox an empty key is rejected on writes.
+ * @dataProvider provide_write_methods
+ *
+ * @param string $method Write method name.
+ */
+ public function test_an_empty_key_is_rejected_on_writes( string $method ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ $this->sut->{$method}( $this->id, '', 'value' );
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_write_methods(): array {
+ return array(
+ 'add' => array( 'add_meta' ),
+ 'update' => array( 'update_meta' ),
+ 'delete' => array( 'delete_meta' ),
+ );
+ }
+}
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 36aed54bb14..68c1f649d60 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
@@ -11,7 +11,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati
use EngineIntegrationTestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
/**
@@ -19,27 +19,53 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepositor
*/
class PlanRepositoryTest extends EngineIntegrationTestCase {
- private function make_plan( PlanRepository $repo, string $name, string $extension_slug, int $sort_order = 0 ): int {
+ /**
+ * Insert a plan with a monthly billing payload.
+ *
+ * @param PlanRepository $repo Repository.
+ * @param string $name Plan name.
+ * @param string|null $extension_slug Owner slug.
+ * @param array<string, mixed> $args Extra Plan::create() args.
+ */
+ private function insert_plan( PlanRepository $repo, string $name, ?string $extension_slug = 'lite', array $args = array() ): int {
return $repo->insert(
Plan::create(
- array(
- 'name' => $name,
- 'billing_policy' => BillingPolicy::from_array(
- array(
+ array_merge(
+ array(
+ 'name' => $name,
+ 'billing_policy' => array(
'period' => 'month',
'interval' => 1,
- )
+ ),
+ 'extension_slug' => $extension_slug,
),
- 'extension_slug' => $extension_slug,
- 'sort_order' => $sort_order,
+ $args
)
)
);
}
- public function test_plan_round_trips_with_policies_and_extension_slug(): void {
- $repo = new PlanRepository();
- $pricing_policy = array(
+ /**
+ * Ids of a plan list.
+ *
+ * @param array<int, Plan> $plans Plans.
+ * @return array<int, int|null>
+ */
+ private static function ids( array $plans ): array {
+ return array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans );
+ }
+
+ /**
+ * @testdox a plan round-trips all three opaque policies.
+ */
+ public function test_plan_round_trips_all_three_opaque_policies(): void {
+ $repo = new PlanRepository();
+ $billing = array(
+ 'period' => 'fortnight',
+ 'interval' => 1,
+ 'trial_duration' => array( 'unit' => 'day' ),
+ );
+ $pricing = array(
'policies' => array(
array(
'type' => 'percentage',
@@ -48,22 +74,19 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
),
'custom_key' => array( 'nested' => '1.50' ),
);
+ $delivery = array(
+ 'anchor' => array( 'day' => 3 ),
+ 'note' => 'opaque',
+ );
$plan = Plan::create(
array(
- 'name' => 'Monthly',
- 'description' => 'A monthly plan',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'max_cycles' => 12,
- )
- ),
- 'pricing_policy' => $pricing_policy,
- 'status' => Plan::STATUS_ARCHIVED,
- 'sort_order' => 4,
- 'extension_slug' => 'lite',
+ 'name' => 'Monthly',
+ 'billing_policy' => $billing,
+ 'pricing_policy' => $pricing,
+ 'delivery_policy' => $delivery,
+ 'status' => PlanStatus::ARCHIVED,
+ 'extension_slug' => 'lite',
)
);
@@ -75,238 +98,282 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
$this->assertInstanceOf( Plan::class, $fetched );
$this->assertSame( 'Monthly', $fetched->get_name() );
- $this->assertSame( 'A monthly plan', $fetched->get_description() );
$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->assertSame( $pricing_policy, $fetched->get_pricing_policy() );
+ $this->assertSame( PlanStatus::ARCHIVED, $fetched->get_status() );
+ $this->assertSame( $billing, $fetched->get_billing_policy() );
+ $this->assertSame( $pricing, $fetched->get_pricing_policy() );
+ $this->assertSame( $delivery, $fetched->get_delivery_policy() );
+ $this->assertNotNull( $fetched->get_date_created_gmt() );
+ $this->assertNotNull( $fetched->get_date_updated_gmt() );
}
- public function test_plan_without_optional_policies_round_trips(): void {
+ /**
+ * @testdox a plan without policies round-trips with null billing.
+ */
+ public function test_plan_without_policies_round_trips_with_null_billing(): void {
$repo = new PlanRepository();
- $id = $repo->insert(
- Plan::create(
- array(
- 'name' => 'Bare',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'week',
- 'interval' => 2,
- )
- ),
- )
+ $plan = Plan::create(
+ array(
+ 'name' => 'Bare',
+ 'extension_slug' => 'lite',
)
);
+ $id = $repo->insert( $plan );
$fetched = $repo->find( $id );
$this->assertInstanceOf( Plan::class, $fetched );
+ $this->assertNull( $fetched->get_billing_policy() );
$this->assertNull( $fetched->get_pricing_policy() );
$this->assertNull( $fetched->get_delivery_policy() );
- $this->assertNull( $fetched->get_extension_slug() );
}
- public function test_merchant_code_round_trips_through_insert_and_find(): void {
+ /**
+ * @testdox update_fields persists the name, status and policies and bumps only the update time.
+ */
+ public function test_update_fields_persists_name_status_and_policies_and_bumps_only_the_updated_date(): void {
+ global $wpdb;
+
$repo = new PlanRepository();
+ $id = $this->insert_plan( $repo, 'Before' );
- $id = $repo->insert(
- Plan::create(
- array(
- 'name' => 'Coded',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- ),
- 'merchant_code' => 'coffee-club',
- )
- )
- );
+ $table = $wpdb->prefix . 'wc_selling_plans';
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET date_created_gmt = %s, date_updated_gmt = %s WHERE id = %d", '2020-01-01 00:00:00', '2020-01-01 00:00:00', $id ) );
- $fetched = $repo->find( $id );
+ $plan = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $plan );
- $this->assertInstanceOf( Plan::class, $fetched );
- $this->assertSame( 'coffee-club', $fetched->get_merchant_code() );
- }
+ $plan->set_name( 'After' );
+ $plan->set_status( PlanStatus::ARCHIVED );
+ $plan->set_billing_policy( array( 'period' => 'week' ) );
+ $plan->set_pricing_policy( array( 'policies' => array() ) );
+ $plan->set_delivery_policy( array( 'x' => 1 ) );
+ $this->assertTrue( $repo->update_fields( $plan, array( 'name', 'status', 'billing_policy', 'pricing_policy', 'delivery_policy' ) ) );
+ $this->assertNotSame( '2020-01-01 00:00:00', $plan->get_date_updated_gmt(), 'The update time is stamped back onto the entity.' );
- public function test_duplicate_merchant_code_insert_throws_within_one_extension(): void {
- $repo = new PlanRepository();
+ $updated = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $updated );
+ $this->assertSame( 'After', $updated->get_name() );
+ $this->assertSame( PlanStatus::ARCHIVED, $updated->get_status() );
+ $this->assertSame( array( 'period' => 'week' ), $updated->get_billing_policy() );
+ $this->assertSame( array( 'policies' => array() ), $updated->get_pricing_policy() );
+ $this->assertSame( array( 'x' => 1 ), $updated->get_delivery_policy() );
+ $this->assertSame( '2020-01-01 00:00:00', $updated->get_date_created_gmt() );
+ $this->assertNotSame( '2020-01-01 00:00:00', $updated->get_date_updated_gmt() );
+
+ $updated->set_billing_policy( null );
+ $repo->update_fields( $updated, array( 'billing_policy' ) );
+ $cleared = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $cleared );
+ $this->assertNull( $cleared->get_billing_policy() );
+ }
- $make = static function ( string $extension_slug ): Plan {
- return Plan::create(
- array(
- 'name' => 'Duplicate code',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- ),
- 'merchant_code' => 'dupe-code',
- 'extension_slug' => $extension_slug,
- )
- );
- };
+ /**
+ * @testdox update_fields on a deleted plan returns false.
+ */
+ public function test_update_fields_returns_false_for_a_deleted_plan(): void {
+ $repo = new PlanRepository();
+ $stale = $repo->find( $this->insert_plan( $repo, 'Gone' ) );
+ $this->assertInstanceOf( Plan::class, $stale );
+ $this->assertTrue( $repo->delete( (int) $stale->get_id() ) );
- $repo->insert( $make( 'lite' ) );
+ $stale->set_name( 'Renamed' );
- $this->expectException( \RuntimeException::class );
- $repo->insert( $make( 'lite' ) );
+ $this->assertFalse( $repo->update_fields( $stale, array( 'name' ) ) );
}
- public function test_same_merchant_code_coexists_across_extensions(): void {
+ /**
+ * @testdox update_fields writing identical values (no changed rows) returns true.
+ */
+ public function test_update_fields_with_identical_values_returns_true(): void {
$repo = new PlanRepository();
+ $plan = $repo->find( $this->insert_plan( $repo, 'Same' ) );
+ $this->assertInstanceOf( Plan::class, $plan );
- $make = static function ( string $extension_slug ): Plan {
- return Plan::create(
- array(
- 'name' => 'Shared code',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- ),
- 'merchant_code' => 'monthly-box',
- 'extension_slug' => $extension_slug,
- )
- );
- };
+ // The first write may bump the update time; the second, within the same second, changes no row.
+ $this->assertTrue( $repo->update_fields( $plan, array( 'name' ) ) );
+ $this->assertTrue( $repo->update_fields( $plan, array( 'name' ) ) );
+ }
- $first_id = $repo->insert( $make( 'lite' ) );
- $second_id = $repo->insert( $make( 'other-extension' ) );
+ /**
+ * @testdox find scoped to an extension slug reads only that extension's plan.
+ */
+ public function test_find_scoped_to_an_extension_slug_reads_only_that_extensions_plan(): void {
+ $repo = new PlanRepository();
+ $id = $this->insert_plan( $repo, 'Owned' );
- $this->assertGreaterThan( 0, $first_id );
- $this->assertGreaterThan( $first_id, $second_id );
+ $this->assertInstanceOf( Plan::class, $repo->find( $id, 'lite' ) );
+ $this->assertNull( $repo->find( $id, 'other-extension' ), 'A plan of another extension reads as missing.' );
+ $this->assertInstanceOf( Plan::class, $repo->find( $id ), 'An unscoped read finds a plan of any extension.' );
}
- public function test_plans_without_merchant_code_coexist(): void {
+ /**
+ * @testdox update_fields writes nothing and returns false when the row belongs to another extension.
+ */
+ public function test_update_fields_writes_nothing_to_a_row_of_another_extension(): void {
+ global $wpdb;
+
$repo = new PlanRepository();
+ $id = $this->insert_plan( $repo, 'Original' );
+ $plan = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $plan );
- $first_id = $this->make_plan( $repo, 'First uncoded', 'lite' );
- $second_id = $this->make_plan( $repo, 'Second uncoded', 'lite' );
+ // The row moves to another extension after the entity was read.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( $wpdb->prefix . 'wc_selling_plans', array( 'extension_slug' => 'other-extension' ), array( 'id' => $id ) );
+ $before = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $before );
- $this->assertGreaterThan( 0, $first_id );
- $this->assertGreaterThan( $first_id, $second_id );
+ // Identical values: the row exists by id, but not for this extension, so it is not "unchanged".
+ $this->assertFalse( $repo->update_fields( $plan, array( 'name' ) ) );
- $first = $repo->find( $first_id );
- $this->assertInstanceOf( Plan::class, $first );
- $this->assertNull( $first->get_merchant_code() );
- }
+ $plan->set_name( 'Renamed' );
+ $this->assertFalse( $repo->update_fields( $plan, array( 'name' ) ) );
- public function test_update_persists_changes(): void {
- $repo = new PlanRepository();
+ $after = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $after );
+ $this->assertSame( $before->to_storage(), $after->to_storage() );
+ }
+ /**
+ * @testdox update_fields without an id throws.
+ */
+ public function test_update_fields_without_an_id_throws(): void {
$plan = Plan::create(
array(
- 'name' => 'Before',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- ),
+ 'name' => 'Unsaved',
+ 'extension_slug' => 'lite',
)
);
- $id = $repo->insert( $plan );
- $plan->set_name( 'After' );
- $plan->set_status( Plan::STATUS_ARCHIVED );
- $plan->set_sort_order( 8 );
- $this->assertTrue( $repo->update( $plan ) );
+ $this->expectException( \RuntimeException::class );
- $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() );
+ ( new PlanRepository() )->update_fields( $plan, array( 'name' ) );
}
- public function test_query_count_and_reorder_use_plan_lifecycle_fields(): void {
+ /**
+ * @testdox update_fields writes only the named columns.
+ */
+ public function test_update_fields_writes_only_the_named_columns(): void {
$repo = new PlanRepository();
+ $id = $this->insert_plan( $repo, 'Original' );
- $first = Plan::create(
- 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(
- 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(
- array(
- 'name' => 'Archived yearly',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'year',
- 'interval' => 1,
- )
- ),
- 'status' => Plan::STATUS_ARCHIVED,
- 'sort_order' => 3,
- 'extension_slug' => 'lite',
- )
- );
+ $stale = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $stale );
+
+ $other = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $other );
+ $other->set_name( 'Renamed elsewhere' );
+ $repo->update_fields( $other, array( 'name' ) );
+
+ $stale->set_status( PlanStatus::ARCHIVED );
+ $repo->update_fields( $stale, array( 'status' ) );
- $first_id = $repo->insert( $first );
- $second_id = $repo->insert( $second );
- $archived_id = $repo->insert( $archived );
+ $stored = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $stored );
+ $this->assertSame( 'Renamed elsewhere', $stored->get_name() );
+ $this->assertSame( PlanStatus::ARCHIVED, $stored->get_status() );
+ }
+
+ /**
+ * @testdox update_fields refuses a field that is not writable.
+ * @testWith ["extension_slug"]
+ * ["sort_order"]
+ *
+ * @param string $field Field that is not a writable column.
+ */
+ public function test_update_fields_refuses_a_field_that_is_not_writable( string $field ): void {
+ $repo = new PlanRepository();
+ $plan = $repo->find( $this->insert_plan( $repo, 'Guarded' ) );
+ $this->assertInstanceOf( Plan::class, $plan );
+
+ $this->expectException( \InvalidArgumentException::class );
+
+ $repo->update_fields( $plan, array( $field ) );
+ }
+
+ /**
+ * @testdox query and count filter by status and search.
+ */
+ public function test_query_and_count_filter_by_status_and_search(): void {
+ $repo = new PlanRepository();
+
+ $this->insert_plan( $repo, 'Alpha monthly' );
+ $second_id = $this->insert_plan( $repo, 'Beta weekly' );
+ $this->insert_plan( $repo, 'Archived yearly', 'lite', array( 'status' => PlanStatus::ARCHIVED ) );
$active = $repo->query(
array(
- 'status' => Plan::STATUS_ACTIVE,
+ 'status' => PlanStatus::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->assertSame( array( $second_id ), self::ids( $active ) );
+ $this->assertSame( 2, $repo->count( array( 'status' => PlanStatus::ACTIVE ) ) );
+ $this->assertSame( 1, $repo->count( array( 'status' => PlanStatus::ARCHIVED ) ) );
+ }
- $this->assertTrue(
- $repo->reorder(
- 'lite',
- array(
- $first_id => 9,
- $second_id => 1,
- $archived_id => 2,
+ /**
+ * @testdox query accepts a status list.
+ */
+ public function test_query_status_accepts_a_list(): void {
+ $repo = new PlanRepository();
+
+ $active_id = $this->insert_plan( $repo, 'Active' );
+ $archived_id = $this->insert_plan( $repo, 'Archived', 'lite', array( 'status' => PlanStatus::ARCHIVED ) );
+
+ $args = array( 'status' => array( PlanStatus::ACTIVE, PlanStatus::ARCHIVED ) );
+
+ $this->assertSame( array( $active_id, $archived_id ), self::ids( $repo->query( $args ) ) );
+ $this->assertSame( 2, $repo->count( $args ) );
+ $this->assertSame( array( $archived_id ), self::ids( $repo->query( array( 'status' => array( PlanStatus::ARCHIVED ) ) ) ) );
+ }
+
+ /**
+ * @testdox query matches nothing for an empty or invalid status list.
+ */
+ public function test_query_empty_or_invalid_status_list_matches_nothing(): void {
+ $repo = new PlanRepository();
+ $this->insert_plan( $repo, 'Active' );
+
+ $this->assertCount( 0, $repo->query( array( 'status' => array() ) ) );
+ $this->assertSame( 0, $repo->count( array( 'status' => array() ) ) );
+ $this->assertCount( 0, $repo->query( array( 'status' => array( PlanStatus::ACTIVE, 5 ) ) ) );
+ $this->assertCount( 0, $repo->query( array( 'status' => '' ) ) );
+ $this->assertCount( 1, $repo->query( array( 'status' => null ) ) );
+ }
+
+ /**
+ * @testdox query defaults to id order and sorts by name.
+ */
+ public function test_query_defaults_to_id_order_and_sorts_by_name(): void {
+ $repo = new PlanRepository();
+
+ $charlie = $this->insert_plan( $repo, 'Charlie' );
+ $alpha = $this->insert_plan( $repo, 'Alpha' );
+ $bravo = $this->insert_plan( $repo, 'Bravo' );
+
+ $this->assertSame( array( $charlie, $alpha, $bravo ), self::ids( $repo->query() ) );
+ $this->assertSame( array( $alpha, $bravo, $charlie ), self::ids( $repo->query( array( 'orderby' => 'name' ) ) ) );
+ $this->assertSame(
+ array( $charlie, $bravo, $alpha ),
+ self::ids(
+ $repo->query(
+ array(
+ 'orderby' => 'name',
+ 'order' => 'desc',
+ )
)
)
);
-
- $ordered = $repo->query(
- array(
- 'orderby' => 'sort_order',
- 'order' => 'asc',
- 'limit' => 3,
- )
+ $this->assertSame(
+ array( $bravo, $alpha, $charlie ),
+ self::ids( $repo->query( array( 'order' => 'desc' ) ) )
);
-
- $this->assertSame( array( $second_id, $archived_id, $first_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $ordered ) );
+ $this->assertSame( array( $charlie, $alpha, $bravo ), self::ids( $repo->query( array( 'orderby' => 'status' ) ) ), 'An unknown orderby falls back to id.' );
}
/**
@@ -332,12 +399,12 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_query_search_terms_starting_with_prepare_specifiers( string $search ): void {
$repo = new PlanRepository();
- $this->make_plan( $repo, 'Unrelated prepare regression plan', 'lite' );
- $expected_id = $this->make_plan( $repo, $search . ' plan', 'lite' );
+ $this->insert_plan( $repo, 'Unrelated prepare regression plan', 'lite' );
+ $expected_id = $this->insert_plan( $repo, $search . ' plan', 'lite' );
$query_args = array(
'extension_slugs' => array( 'lite' ),
- 'status' => Plan::STATUS_ACTIVE,
+ 'status' => PlanStatus::ACTIVE,
'search' => $search,
'orderby' => 'id',
'order' => 'asc',
@@ -355,9 +422,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_invalid_extension_scopes_do_not_return_unscoped_results(): void {
$repo = new PlanRepository();
- $id = $this->make_plan( $repo, 'Scoped', 'lite' );
+ $this->insert_plan( $repo, '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' ) ) ) );
@@ -365,8 +431,6 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
$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_slugs' => array() ) ) );
$this->assertSame( 0, $repo->count( array( 'extension_slugs' => array() ) ) );
$this->assertCount( 0, $repo->query( array( 'extension_slugs' => array( '' ) ) ) );
@@ -378,8 +442,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_query_extension_slugs_filters_by_single_and_multiple_slugs(): void {
$repo = new PlanRepository();
- $lite_id = $this->make_plan( $repo, 'Lite plan', 'lite', 1 );
- $other_id = $this->make_plan( $repo, 'Other plan', 'other-extension', 2 );
+ $lite_id = $this->insert_plan( $repo, 'Lite plan', 'lite' );
+ $other_id = $this->insert_plan( $repo, 'Other plan', 'other-extension' );
$single = $repo->query( array( 'extension_slugs' => array( 'lite' ) ) );
$this->assertSame( array( $lite_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $single ) );
@@ -392,57 +456,19 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_query_singular_extension_slug_arg_is_unknown_and_ignored(): void {
$repo = new PlanRepository();
- $plan_id = $this->make_plan( $repo, 'Scoped', 'lite' );
+ $plan_id = $this->insert_plan( $repo, 'Scoped', 'lite' );
$plans = $repo->query( array( 'extension_slug' => 'other-extension' ) );
$this->assertSame( array( $plan_id ), array_map( static fn ( Plan $plan ): ?int => $plan->get_id(), $plans ) );
$this->assertSame( 1, $repo->count( array( 'extension_slug' => '' ) ) );
}
- public function test_reorder_fails_before_updates_when_an_id_is_missing_or_outside_extension(): void {
- $repo = new PlanRepository();
-
- $first_id = $this->make_plan( $repo, 'First', 'lite', 1 );
- $other_id = $this->make_plan( $repo, '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_query_ids_returns_only_those_plans(): void {
$repo = new PlanRepository();
- $first_plan_id = $this->make_plan( $repo, 'First', 'lite', 1 );
- $second_plan_id = $this->make_plan( $repo, 'Second', 'lite', 2 );
- $this->make_plan( $repo, 'Third', 'lite', 3 );
+ $first_plan_id = $this->insert_plan( $repo, 'First', 'lite' );
+ $second_plan_id = $this->insert_plan( $repo, 'Second', 'lite' );
+ $this->insert_plan( $repo, 'Third', 'lite' );
$plans = $repo->query( array( 'ids' => array( $first_plan_id, $second_plan_id ) ) );
@@ -453,17 +479,17 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_query_ids_composes_with_status_and_extension_slugs(): void {
$repo = new PlanRepository();
- $active_id = $this->make_plan( $repo, 'Active lite', 'lite', 1 );
- $foreign_id = $this->make_plan( $repo, 'Other extension', 'other-extension', 2 );
+ $active_id = $this->insert_plan( $repo, 'Active lite', 'lite' );
+ $foreign_id = $this->insert_plan( $repo, 'Other extension', 'other-extension' );
- $archived = $repo->find( $this->make_plan( $repo, 'Archived lite', 'lite', 3 ) );
+ $archived = $repo->find( $this->insert_plan( $repo, 'Archived lite', 'lite' ) );
$this->assertInstanceOf( Plan::class, $archived );
- $archived->set_status( Plan::STATUS_ARCHIVED );
- $this->assertTrue( $repo->update( $archived ) );
+ $archived->set_status( PlanStatus::ARCHIVED );
+ $repo->update_fields( $archived, array( 'status' ) );
$plans = $repo->query(
array(
- 'status' => Plan::STATUS_ACTIVE,
+ 'status' => PlanStatus::ACTIVE,
'extension_slugs' => array( 'lite' ),
'ids' => array( $active_id, $foreign_id, (int) $archived->get_id() ),
)
@@ -475,7 +501,7 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_query_empty_or_invalid_ids_match_nothing(): void {
$repo = new PlanRepository();
- $plan_id = $this->make_plan( $repo, 'Plan', 'lite' );
+ $plan_id = $this->insert_plan( $repo, 'Plan', 'lite' );
$this->assertCount( 0, $repo->query( array( 'ids' => array() ) ) );
$this->assertSame( 0, $repo->count( array( 'ids' => array() ) ) );
@@ -487,8 +513,8 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_query_null_ids_behaves_as_arg_absent(): void {
$repo = new PlanRepository();
- $first_plan_id = $this->make_plan( $repo, 'First', 'lite', 1 );
- $second_plan_id = $this->make_plan( $repo, 'Second', 'lite', 2 );
+ $first_plan_id = $this->insert_plan( $repo, 'First', 'lite' );
+ $second_plan_id = $this->insert_plan( $repo, 'Second', 'lite' );
$plans = $repo->query( array( 'ids' => null ) );
@@ -499,19 +525,7 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
public function test_delete_removes_the_row(): void {
$repo = new PlanRepository();
- $id = $repo->insert(
- Plan::create(
- array(
- 'name' => 'Doomed',
- 'billing_policy' => BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- ),
- )
- )
- );
+ $id = $this->insert_plan( $repo, 'Doomed' );
$this->assertTrue( $repo->delete( $id ) );
$this->assertNull( $repo->find( $id ) );
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 bb9251991f9..2ac69c8ffd2 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
@@ -26,6 +26,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
public function table_provider(): array {
return array(
array( SchemaInstaller::TABLE_PLANS ),
+ array( SchemaInstaller::TABLE_PLAN_META ),
array( SchemaInstaller::TABLE_CONTRACTS ),
array( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
array( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ),
@@ -101,18 +102,56 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
$this->assertSame( 'extension_slug', $column );
}
- public function test_plans_table_has_status_and_sort_order_columns(): void {
+ /**
+ * @testdox The plans table keeps status, drops the retired columns, and has a nullable billing_policy.
+ */
+ public function test_plans_table_columns_after_the_records_rework(): void {
global $wpdb;
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ $this->assertTrue( $this->has_column( $table, 'status' ), 'Expected plans.status.' );
+
+ foreach ( array( 'description', 'category', 'sort_order', 'merchant_code', 'inventory_policy' ) as $column ) {
+ $this->assertFalse( $this->has_column( $table, $column ), "Did not expect plans.{$column}." );
+ }
+
// 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' ) );
+ $row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'billing_policy' ), ARRAY_A );
+
+ $this->assertIsArray( $row, 'Expected a plans.billing_policy column.' );
+ $this->assertSame( 'YES', $row['Null'] ?? null, 'Expected plans.billing_policy to be NULLable.' );
+ }
+
+ /**
+ * @testdox The plans table is indexed by (extension_slug, status) and carries no retired indexes.
+ */
+ public function test_plans_table_indexes(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
+ $indexes = $this->index_names( $table );
- $this->assertSame( 'status', $status );
- $this->assertSame( 'sort_order', $sort_order );
+ $this->assertContains( 'extension_status', $indexes );
+ $this->assertSame( array( 'extension_slug', 'status' ), $this->index_columns( $table, 'extension_status' ) );
+
+ foreach ( array( 'category', 'status_sort', 'extension_merchant_code' ) as $index ) {
+ $this->assertNotContains( $index, $indexes, "Did not expect the plans {$index} index." );
+ }
+ }
+
+ /**
+ * @testdox Plan meta carries the HPOS-style key/value indexes.
+ */
+ public function test_plan_meta_has_hpos_style_indexes(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLAN_META );
+ $indexes = $this->index_names( $table );
+
+ $this->assertContains( 'meta_key_value', $indexes );
+ $this->assertContains( 'plan_meta_key_value', $indexes );
+ $this->assertSame( array( 'meta_key', 'meta_value' ), $this->index_columns( $table, 'meta_key_value' ) );
+ $this->assertSame(
+ array( 'plan_id', 'meta_key', 'meta_value' ),
+ $this->index_columns( $table, 'plan_meta_key_value' )
+ );
}
public function test_contracts_table_has_extension_slug_column(): void {
@@ -251,10 +290,10 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
}
/**
- * @testdox The schema version is 2.5.0 (nullable contract identity columns, HPOS-style meta indexes).
+ * @testdox The schema version is 2.6.0 (plans as records, plan meta table).
*/
- public function test_schema_version_is_2_5_0(): void {
- $this->assertSame( '2.5.0', SchemaInstaller::get_version() );
+ public function test_schema_version_is_2_6_0(): void {
+ $this->assertSame( '2.6.0', SchemaInstaller::get_version() );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
index a3e210e1cdf..9671edfa1bc 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
@@ -48,6 +48,14 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
'money' => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
'money null is zero' => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
+ 'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
+ 'non-negative digits' => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
+ 'id list' => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
+ 'empty id list' => array( 'validate_id_list', array( 'ids', array() ), array() ),
+ 'string as list' => array( 'validate_string_list', array( 'status', 'active' ), array( 'active' ) ),
+ 'string list' => array( 'validate_string_list', array( 'status', array( 'active', 'archived' ) ), array( 'active', 'archived' ) ),
+ 'nullable array' => array( 'validate_nullable_array', array( 'billing_policy', array( 'period' => 'month' ) ), array( 'period' => 'month' ) ),
+ 'nullable array null' => array( 'validate_nullable_array', array( 'billing_policy', null ), null ),
);
}
@@ -70,13 +78,22 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
*/
public function provide_invalid_values(): array {
return array(
- 'currency' => array( 'validate_currency', array( 'eur' ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
- 'string' => array( 'validate_string', array( 'status', 5 ), '"status" must be a string.' ),
- 'nullable string' => array( 'validate_nullable_string', array( 'title', 5 ), '"title" must be null or a string.' ),
- 'nullable id' => array( 'validate_nullable_id', array( 'order_id', 0 ), '"order_id" must be null or a positive integer.' ),
- 'nullable date' => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), '"start_gmt" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.' ),
- 'money' => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
- 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
+ 'currency' => array( 'validate_currency', array( 'eur' ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
+ 'string' => array( 'validate_string', array( 'status', 5 ), '"status" must be a string.' ),
+ 'nullable string' => array( 'validate_nullable_string', array( 'title', 5 ), '"title" must be null or a string.' ),
+ 'nullable id' => array( 'validate_nullable_id', array( 'order_id', 0 ), '"order_id" must be null or a positive integer.' ),
+ 'nullable date' => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), '"start_gmt" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.' ),
+ 'money' => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
+ 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
+ 'negative int' => array( 'validate_non_negative_int', array( 'offset', -1 ), '"offset" must be a non-negative integer.' ),
+ 'id list entry' => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), '"ids" must be a list of positive integers.' ),
+ 'id list null' => array( 'validate_id_list', array( 'ids', array( null ) ), '"ids" must be a list of positive integers.' ),
+ 'id list map' => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), '"ids" must be a list of positive integers.' ),
+ 'id newline' => array( 'validate_nullable_id', array( 'order_id', "5\n" ), '"order_id" must be null or a positive integer.' ),
+ 'currency newline' => array( 'validate_currency', array( "EUR\n" ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
+ 'string list' => array( 'validate_string_list', array( 'status', array( 'active', '' ) ), '"status" must be a non-empty string or a list of them.' ),
+ 'string list int' => array( 'validate_string_list', array( 'status', 5 ), '"status" must be a non-empty string or a list of them.' ),
+ 'nullable array' => array( 'validate_nullable_array', array( 'billing_policy', 'monthly' ), '"billing_policy" must be null or an array.' ),
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/PlanViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/PlanViewTest.php
new file mode 100644
index 00000000000..01faea756bd
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/PlanViewTest.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Unit tests for the PlanView DTO.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api\View;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView
+ */
+class PlanViewTest extends TestCase {
+
+ public function test_every_getter_maps_from_a_stored_plan(): void {
+ $billing = array(
+ 'period' => 'month',
+ 'interval' => 1,
+ );
+ $pricing = array( 'policies' => array( array( 'type' => 'percentage' ) ) );
+ $delivery = array( 'anchor' => 3 );
+
+ $view = PlanView::from_plan(
+ Plan::from_storage(
+ array(
+ 'id' => 7,
+ 'name' => 'Monthly',
+ 'status' => 'archived',
+ 'extension_slug' => 'acme-subs',
+ 'billing_policy' => $billing,
+ 'pricing_policy' => $pricing,
+ 'delivery_policy' => $delivery,
+ 'date_created_gmt' => '2026-01-01 00:00:00',
+ 'date_updated_gmt' => '2026-01-02 00:00:00',
+ )
+ )
+ );
+
+ $this->assertSame( 7, $view->get_id() );
+ $this->assertSame( 'acme-subs', $view->get_extension_slug() );
+ $this->assertSame( 'archived', $view->get_status() );
+ $this->assertSame( 'Monthly', $view->get_name() );
+ $this->assertSame( $billing, $view->get_billing_policy() );
+ $this->assertSame( $pricing, $view->get_pricing_policy() );
+ $this->assertSame( $delivery, $view->get_delivery_policy() );
+ $this->assertSame( '2026-01-01 00:00:00', $view->get_date_created_gmt() );
+ $this->assertSame( '2026-01-02 00:00:00', $view->get_date_updated_gmt() );
+ }
+
+ public function test_an_unsaved_plan_has_id_zero_and_no_dates(): void {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Draft',
+ 'extension_slug' => 'my-ext',
+ )
+ );
+ $view = PlanView::from_plan( $plan );
+
+ $this->assertSame( 0, $view->get_id() );
+ $this->assertSame( 'my-ext', $view->get_extension_slug() );
+ $this->assertNull( $view->get_date_created_gmt() );
+ $this->assertNull( $view->get_date_updated_gmt() );
+ }
+
+ public function test_null_policies_stay_null(): void {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Bare',
+ 'extension_slug' => 'my-ext',
+ )
+ );
+ $view = PlanView::from_plan( $plan );
+
+ $this->assertNull( $view->get_billing_policy() );
+ $this->assertNull( $view->get_pricing_policy() );
+ $this->assertNull( $view->get_delivery_policy() );
+ }
+
+ public function test_the_view_is_final_with_no_public_state_and_only_getters(): void {
+ $reflection = new \ReflectionClass( PlanView::class );
+
+ $this->assertTrue( $reflection->isFinal(), 'No subclass can add write access to the view.' );
+ $this->assertSame( array(), $reflection->getProperties( \ReflectionProperty::IS_PUBLIC ), 'The view has no writable state.' );
+ foreach ( $reflection->getMethods( \ReflectionMethod::IS_PUBLIC ) as $method ) {
+ if ( $method->isStatic() || $method->isConstructor() ) {
+ continue;
+ }
+ $this->assertStringStartsWith( 'get_', $method->getName(), 'The view exposes getters only, no mutators.' );
+ }
+ }
+
+ public function test_the_view_does_not_follow_later_entity_changes(): void {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Before',
+ 'extension_slug' => 'my-ext',
+ 'pricing_policy' => array( 'a' => 1 ),
+ )
+ );
+ $view = PlanView::from_plan( $plan );
+
+ $plan->set_name( 'After' );
+ $plan->set_pricing_policy( array( 'b' => 2 ) );
+
+ $this->assertSame( 'Before', $view->get_name() );
+ $this->assertSame( array( 'a' => 1 ), $view->get_pricing_policy() );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanStatusTest.php
new file mode 100644
index 00000000000..654c9c26dd5
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanStatusTest.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * Unit tests for the registry-backed PlanStatus helpers.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus
+ */
+class PlanStatusTest extends TestCase {
+
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
+ }
+
+ public function test_defaults_lists_the_two_engine_slugs(): void {
+ $this->assertSame( array( 'active', 'archived' ), PlanStatus::get_defaults() );
+ }
+
+ public function test_all_equals_the_defaults_with_nothing_registered(): void {
+ $this->assertSame( PlanStatus::get_defaults(), PlanStatus::get_all() );
+ }
+
+ public function test_defaults_are_registered(): void {
+ $this->assertTrue( PlanStatus::is_registered( PlanStatus::ACTIVE ) );
+ $this->assertTrue( PlanStatus::is_registered( PlanStatus::ARCHIVED ) );
+ $this->assertFalse( PlanStatus::is_registered( 'nonsense' ) );
+ }
+
+ public function test_an_extension_registered_status_is_listed_and_registered(): void {
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
+ $all = PlanStatus::get_all();
+
+ $this->assertSame( 'seasonal', end( $all ) );
+ $this->assertTrue( PlanStatus::is_registered( 'seasonal' ) );
+ }
+
+ public function test_a_contract_registration_does_not_make_a_plan_status_registered(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+ $this->assertFalse( PlanStatus::is_registered( 'paused-by-merchant' ) );
+ }
+
+ public function test_is_valid_checks_the_slug_format_only(): void {
+ $this->assertTrue( PlanStatus::is_valid( PlanStatus::ARCHIVED ) );
+ $this->assertTrue( PlanStatus::is_valid( 'legacy-x' ), 'Well-formed but unregistered.' );
+ $this->assertFalse( PlanStatus::is_registered( 'legacy-x' ) );
+ $this->assertFalse( PlanStatus::is_valid( 'Archived' ) );
+ $this->assertFalse( PlanStatus::is_valid( str_repeat( 'a', 21 ) ) );
+ }
+}
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 83657b9af0b..a66398c4ef4 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
@@ -9,244 +9,418 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-use InvalidArgumentException;
+use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan
*/
class PlanTest extends TestCase {
- private function billing(): BillingPolicy {
- return BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- );
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
}
- public function test_create_defaults_category_and_extension_slug(): void {
+ /**
+ * @testdox create applies the defaults.
+ */
+ public function test_create_defaults(): void {
$plan = Plan::create(
array(
'name' => 'Monthly box',
- 'billing_policy' => $this->billing(),
+ 'extension_slug' => 'my-ext',
)
);
$this->assertNull( $plan->get_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_merchant_code() );
- $this->assertNull( $plan->get_extension_slug() );
+ $this->assertSame( 'Monthly box', $plan->get_name() );
+ $this->assertSame( PlanStatus::ACTIVE, $plan->get_status() );
+ $this->assertSame( 'my-ext', $plan->get_extension_slug() );
+ $this->assertNull( $plan->get_billing_policy() );
+ $this->assertNull( $plan->get_pricing_policy() );
+ $this->assertNull( $plan->get_delivery_policy() );
+ $this->assertNull( $plan->get_date_created_gmt() );
+ $this->assertNull( $plan->get_date_updated_gmt() );
}
- public function test_merchant_code_round_trips_through_create_and_storage(): void {
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_policy_fields(): array {
+ return array(
+ 'billing' => array( 'billing_policy' ),
+ 'pricing' => array( 'pricing_policy' ),
+ 'delivery' => array( 'delivery_policy' ),
+ );
+ }
+
+ /**
+ * @testdox a policy round-trips opaquely through storage.
+ * @dataProvider provide_policy_fields
+ *
+ * @param string $field Policy field.
+ */
+ public function test_a_policy_round_trips_opaquely_through_storage( string $field ): void {
+ $payload = array(
+ 'period' => 'fortnight',
+ 'anything' => array(
+ 'nested' => array( 1, 'two', array( 'three' => true ) ),
+ 'empty' => array(),
+ ),
+ 'flag' => null,
+ );
+
$plan = Plan::create(
array(
- 'name' => 'Coded',
- 'billing_policy' => $this->billing(),
- 'merchant_code' => 'monthly-box',
+ 'name' => 'Opaque',
+ 'extension_slug' => 'my-ext',
+ $field => $payload,
)
);
- $this->assertSame( 'monthly-box', $plan->get_merchant_code() );
- $this->assertSame( 'monthly-box', $plan->to_storage()['merchant_code'] );
-
- $hydrated = Plan::from_storage( $plan->to_storage() );
+ $storage = $plan->to_storage();
+ $this->assertSame( $payload, $storage[ $field ] );
- $this->assertSame( 'monthly-box', $hydrated->get_merchant_code() );
+ $hydrated = Plan::from_storage( $storage );
+ $this->assertSame( $payload, $this->policy( $hydrated, $field ) );
+ $this->assertSame( 'my-ext', $hydrated->get_extension_slug() );
}
- public function test_absent_merchant_code_is_null_in_storage(): void {
- $plan = Plan::create(
+ /**
+ * @testdox an empty object policy is kept as an empty array.
+ * @dataProvider provide_policy_fields
+ *
+ * @param string $field Policy field.
+ */
+ public function test_an_empty_object_policy_is_kept_as_an_empty_array( string $field ): void {
+ $plan = Plan::from_storage(
array(
- 'name' => 'Uncoded',
- 'billing_policy' => $this->billing(),
+ 'name' => 'Empty',
+ $field => array(),
)
);
- $this->assertNull( $plan->to_storage()['merchant_code'] );
- $this->assertNull( Plan::from_storage( $plan->to_storage() )->get_merchant_code() );
+ $this->assertSame( array(), $this->policy( $plan, $field ) );
}
- public function test_status_and_sort_order_are_mutable(): void {
+ /**
+ * @testdox a null policy stays null.
+ * @dataProvider provide_policy_fields
+ *
+ * @param string $field Policy field.
+ */
+ public function test_a_null_policy_stays_null( string $field ): void {
$plan = Plan::create(
array(
- 'name' => 'Ordered',
- 'billing_policy' => $this->billing(),
- 'sort_order' => 3,
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Null',
+ $field => null,
)
);
- $plan->set_status( Plan::STATUS_ARCHIVED );
- $plan->set_sort_order( 7 );
+ $this->assertNull( $plan->to_storage()[ $field ] );
+ $this->assertNull( $this->policy( Plan::from_storage( $plan->to_storage() ), $field ) );
+ }
- $this->assertSame( Plan::STATUS_ARCHIVED, $plan->get_status() );
- $this->assertSame( 7, $plan->get_sort_order() );
+ /**
+ * @return array<string, array{0: string, 1: mixed}>
+ */
+ public function provide_bad_policy_values(): array {
+ $cases = array();
+ foreach ( array( 'billing_policy', 'pricing_policy', 'delivery_policy' ) as $field ) {
+ $cases[ "{$field} list" ] = array( $field, array( 'a', 'b' ) );
+ $cases[ "{$field} scalar" ] = array( $field, 'monthly' );
+ $cases[ "{$field} int" ] = array( $field, 5 );
+ }
+ return $cases;
}
- public function test_invalid_status_is_rejected(): void {
- $this->expectException( InvalidArgumentException::class );
+ /**
+ * @testdox create rejects a non-object policy.
+ * @dataProvider provide_bad_policy_values
+ *
+ * @param string $field Policy field.
+ * @param mixed $value Bad value.
+ */
+ public function test_create_rejects_a_non_object_policy( string $field, $value ): void {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( $field );
Plan::create(
array(
- 'name' => 'Bad status',
- 'billing_policy' => $this->billing(),
- 'status' => 'deleted',
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Bad',
+ $field => $value,
)
);
}
- public function test_to_storage_exposes_extension_slug_and_decoded_policies(): void {
+ /**
+ * @testdox create accepts a policy object keyed by numeric ids.
+ * @dataProvider provide_policy_fields
+ *
+ * @param string $field Policy field.
+ */
+ public function test_create_accepts_a_policy_object_keyed_by_numeric_ids( string $field ): void {
+ $payload = json_decode( '{"123": {"price": "9.00"}, "456": {"price": "12.00"}}', true );
+
$plan = Plan::create(
array(
- 'name' => 'Owned',
- 'billing_policy' => $this->billing(),
- 'status' => Plan::STATUS_ARCHIVED,
- 'sort_order' => 9,
- 'extension_slug' => 'lite',
- 'pricing_policy' => array( 'policies' => array() ),
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Per variation',
+ $field => $payload,
)
);
- $storage = $plan->to_storage();
+ $this->assertSame( $payload, $this->policy( $plan, $field ) );
+ }
- $this->assertSame( 'lite', $storage['extension_slug'] );
- $this->assertSame( Plan::STATUS_ARCHIVED, $storage['status'] );
- $this->assertSame( 9, $storage['sort_order'] );
- $this->assertIsArray( $storage['billing_policy'] );
- $this->assertSame( array( 'policies' => array() ), $storage['pricing_policy'] );
+ /**
+ * @testdox from_storage hydrates a stored list policy as it is and nulls a non-array one, without validating.
+ */
+ public function test_from_storage_does_not_validate(): void {
+ $plan = Plan::from_storage(
+ array(
+ 'name' => '',
+ 'status' => 'retired-by-ext',
+ 'billing_policy' => array( 'a', 'b' ),
+ 'pricing_policy' => 'monthly',
+ 'delivery_policy' => 5,
+ )
+ );
+
+ $this->assertSame( '', $plan->get_name() );
+ $this->assertNull( $plan->get_extension_slug() );
+ $this->assertSame( array( 'a', 'b' ), $plan->get_billing_policy() );
+ $this->assertNull( $plan->get_pricing_policy() );
+ $this->assertNull( $plan->get_delivery_policy() );
}
/**
- * An arbitrary extension payload, including vocabulary the engine does not know.
+ * @testdox create requires a non-empty name and an extension slug.
+ * @dataProvider provide_missing_required_args
*
- * @return array<string, mixed>
+ * @param array<string, mixed> $args Create args.
+ * @param string $message Expected message.
+ */
+ public function test_create_requires_a_name_and_an_extension_slug( array $args, string $message ): void {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( $message );
+
+ Plan::create( $args );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>, 1: string}>
*/
- private function arbitrary_payload(): array {
+ public function provide_missing_required_args(): array {
return array(
- 'policies' => array(
+ 'no name' => array( array( 'extension_slug' => 'my-ext' ), 'Plan: name is required and must be a non-empty string.' ),
+ 'blank name' => array(
array(
- 'type' => 'tiered',
- 'value' => -5,
- 'tiers' => array( array( 'min' => 1 ), array( 'min' => 10 ) ),
+ 'extension_slug' => 'my-ext',
+ 'name' => ' ',
),
- array( 'type' => 'bogo' ),
+ 'Plan: name is required and must be a non-empty string.',
),
- 'one_time_fees' => array(),
- 'custom_key' => array( 'nested' => array( 'deep' => '1.50' ) ),
+ 'no extension slug' => array( array( 'name' => 'Box' ), 'Plan: extension_slug is required and must be a non-empty string.' ),
);
}
- public function test_pricing_payload_round_trips_opaquely_through_storage(): void {
- $plan = Plan::create(
+ /**
+ * @testdox set_name rejects an empty name.
+ */
+ public function test_set_name_rejects_an_empty_name(): void {
+ $plan = self::create_plan( 'Named' );
+
+ $this->expectException( DomainException::class );
+
+ $plan->set_name( '' );
+ }
+
+ /**
+ * @testdox a setter rejects a list policy.
+ * @dataProvider provide_policy_fields
+ *
+ * @param string $field Policy field.
+ */
+ public function test_a_setter_rejects_a_list_policy( string $field ): void {
+ $plan = self::create_plan( 'Bad' );
+ $setter = 'set_' . $field;
+
+ $this->expectException( DomainException::class );
+
+ $plan->{$setter}( array( 'a', 'b' ) );
+ }
+
+ /**
+ * @testdox a setter replaces the whole payload.
+ * @dataProvider provide_policy_fields
+ *
+ * @param string $field Policy field.
+ */
+ public function test_a_setter_replaces_the_whole_payload( string $field ): void {
+ $plan = Plan::create(
array(
- 'name' => 'Opaque',
- 'billing_policy' => $this->billing(),
- 'pricing_policy' => $this->arbitrary_payload(),
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Replace',
+ $field => array(
+ 'a' => 1,
+ 'b' => 2,
+ ),
)
);
+ $setter = 'set_' . $field;
- $this->assertSame( $this->arbitrary_payload(), $plan->get_pricing_policy() );
- $this->assertSame( $this->arbitrary_payload(), $plan->to_storage()['pricing_policy'] );
-
- $hydrated = Plan::from_storage( $plan->to_storage() );
+ $plan->{$setter}( array( 'c' => 3 ) );
+ $this->assertSame( array( 'c' => 3 ), $this->policy( $plan, $field ) );
- $this->assertSame( $this->arbitrary_payload(), $hydrated->get_pricing_policy() );
+ $plan->{$setter}( null );
+ $this->assertNull( $this->policy( $plan, $field ) );
}
- public function test_set_pricing_policy_round_trips_and_clears(): void {
- $plan = Plan::create(
+ /**
+ * @testdox create rejects an unregistered status.
+ */
+ public function test_create_rejects_an_unregistered_status(): void {
+ $this->expectException( DomainException::class );
+
+ Plan::create(
array(
- 'name' => 'Mutating',
- 'billing_policy' => $this->billing(),
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Unknown',
+ 'status' => 'seasonal',
)
);
+ }
- $plan->set_pricing_policy( $this->arbitrary_payload() );
- $this->assertSame( $this->arbitrary_payload(), $plan->get_pricing_policy() );
+ /**
+ * @testdox set_status rejects an unregistered status.
+ */
+ public function test_set_status_rejects_an_unregistered_status(): void {
+ $plan = self::create_plan( 'Unknown' );
- $plan->set_pricing_policy( null );
- $this->assertNull( $plan->get_pricing_policy() );
+ $this->expectException( DomainException::class );
+
+ $plan->set_status( 'seasonal' );
}
- public function test_absent_pricing_payload_stays_null(): void {
+ /**
+ * @testdox a registered extension status is accepted.
+ */
+ public function test_a_registered_extension_status_is_accepted(): void {
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+
$plan = Plan::create(
array(
- 'name' => 'Plain',
- 'billing_policy' => $this->billing(),
+ 'extension_slug' => 'my-ext',
+ 'name' => 'Seasonal',
+ 'status' => 'seasonal',
)
);
+ $this->assertSame( 'seasonal', $plan->get_status() );
- $this->assertNull( $plan->get_pricing_policy() );
- $this->assertNull( $plan->to_storage()['pricing_policy'] );
- $this->assertNull( Plan::from_storage( $plan->to_storage() )->get_pricing_policy() );
+ $plan->set_status( PlanStatus::ARCHIVED );
+ $this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+ $this->assertSame( PlanStatus::ARCHIVED, $plan->to_storage()['status'] );
}
- public function test_empty_pricing_payload_is_accepted(): void {
- $plan = Plan::create(
+ /**
+ * @testdox from_storage hydrates an unregistered stored status.
+ */
+ public function test_from_storage_hydrates_an_unregistered_stored_status(): void {
+ $plan = Plan::from_storage(
array(
- 'name' => 'Empty',
- 'billing_policy' => $this->billing(),
- 'pricing_policy' => array(),
+ 'id' => 5,
+ 'name' => 'Legacy',
+ 'status' => 'retired-by-ext',
)
);
- $this->assertSame( array(), $plan->get_pricing_policy() );
- $this->assertSame( array(), Plan::from_storage( $plan->to_storage() )->get_pricing_policy() );
+ $this->assertSame( 'retired-by-ext', $plan->get_status() );
+
+ $plan->set_status( 'retired-by-ext' );
+ $this->assertSame( 'retired-by-ext', $plan->to_storage()['status'] );
}
/**
- * @return array<string, array{0: mixed}>
+ * @testdox from_storage hydrates the id and dates.
*/
- public function non_object_payloads(): array {
- return array(
- 'list' => array( array( array( 'type' => 'x' ) ) ),
- 'string' => array( 'percentage' ),
- 'int' => array( 10 ),
+ public function test_from_storage_hydrates_the_id_and_dates(): void {
+ $plan = Plan::from_storage(
+ array(
+ 'id' => '12',
+ 'name' => 'Stored',
+ 'status' => 'archived',
+ 'date_created_gmt' => '2026-01-02 03:04:05',
+ 'date_updated_gmt' => '2026-02-03 04:05:06',
+ )
);
+
+ $this->assertSame( 12, $plan->get_id() );
+ $this->assertSame( PlanStatus::ARCHIVED, $plan->get_status() );
+ $this->assertSame( '2026-01-02 03:04:05', $plan->get_date_created_gmt() );
+ $this->assertSame( '2026-02-03 04:05:06', $plan->get_date_updated_gmt() );
}
/**
- * @dataProvider non_object_payloads
- *
- * @param mixed $payload Non-object payload.
+ * @testdox to_storage has exactly the record columns.
*/
- public function test_create_rejects_a_non_object_pricing_payload( $payload ): void {
- $this->expectException( InvalidArgumentException::class );
- $this->expectExceptionMessage( 'pricing_policy must be an object' );
+ public function test_to_storage_has_exactly_the_record_columns(): void {
+ $plan = self::create_plan( 'Columns' );
- Plan::create(
- array(
- 'name' => 'Bad',
- 'billing_policy' => $this->billing(),
- 'pricing_policy' => $payload,
- )
+ $this->assertEqualsCanonicalizing(
+ array( 'name', 'status', 'extension_slug', 'billing_policy', 'pricing_policy', 'delivery_policy' ),
+ array_keys( $plan->to_storage() )
);
}
/**
- * @dataProvider non_object_payloads
- *
- * @param mixed $payload Non-object payload.
+ * @testdox name and id are mutable.
*/
- public function test_from_storage_rejects_a_non_object_pricing_payload( $payload ): void {
- $this->expectException( InvalidArgumentException::class );
- $this->expectExceptionMessage( 'pricing_policy must be an object' );
+ public function test_name_and_id_are_mutable(): void {
+ $plan = self::create_plan( 'Before' );
- Plan::from_storage(
+ $plan->set_name( 'After' );
+ $plan->set_id( 9 );
+
+ $this->assertSame( 'After', $plan->get_name() );
+ $this->assertSame( 9, $plan->get_id() );
+ }
+
+ /**
+ * A new plan owned by `my-ext`.
+ *
+ * @param string $name Plan name.
+ */
+ private static function create_plan( string $name ): Plan {
+ return Plan::create(
array(
- 'name' => 'Corrupted',
- 'billing_policy' => array(
- 'period' => 'month',
- 'interval' => 1,
- ),
- 'pricing_policy' => $payload,
+ 'name' => $name,
+ 'extension_slug' => 'my-ext',
)
);
}
+
+ /**
+ * Read a policy by field name.
+ *
+ * @param Plan $plan Plan.
+ * @param string $field Policy field.
+ * @return array<string, mixed>|null
+ */
+ private function policy( Plan $plan, string $field ): ?array {
+ switch ( $field ) {
+ case 'billing_policy':
+ return $plan->get_billing_policy();
+ case 'pricing_policy':
+ return $plan->get_pricing_policy();
+ default:
+ return $plan->get_delivery_policy();
+ }
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
index b17d1c8e9e0..dee6faa96c7 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
@@ -13,6 +13,7 @@ use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
/**
@@ -34,6 +35,24 @@ class StatusRegistryTest extends TestCase {
array( 'pending', 'processing', 'billed', 'failed', 'cancelled' ),
StatusRegistry::get_all( StatusRegistry::KIND_CYCLE )
);
+ $this->assertSame(
+ array( 'active', 'archived' ),
+ StatusRegistry::get_all( StatusRegistry::KIND_PLAN )
+ );
+ }
+
+ public function test_plan_registrations_are_independent_of_contract_and_cycle(): void {
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+ $this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, 'seasonal' ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'seasonal' ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'seasonal' ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, 'paused-by-merchant' ) );
+ $this->assertSame(
+ array_merge( PlanStatus::get_defaults(), array( 'seasonal' ) ),
+ StatusRegistry::get_all( StatusRegistry::KIND_PLAN )
+ );
}
public function test_registered_statuses_append_after_the_defaults_in_registration_order(): void {
@@ -138,29 +157,32 @@ class StatusRegistryTest extends TestCase {
public function test_register_rejects_an_unknown_kind(): void {
$this->expectException( InvalidArgumentException::class );
- StatusRegistry::register( 'plan', 'draft' );
+ StatusRegistry::register( 'product', 'draft' );
}
public function test_all_rejects_an_unknown_kind(): void {
$this->expectException( InvalidArgumentException::class );
- StatusRegistry::get_all( 'plan' );
+ StatusRegistry::get_all( 'product' );
}
public function test_is_registered_rejects_an_unknown_kind(): void {
$this->expectException( InvalidArgumentException::class );
- StatusRegistry::is_registered( 'plan', 'draft' );
+ StatusRegistry::is_registered( 'product', 'draft' );
}
public function test_reset_clears_registrations_but_keeps_the_defaults(): void {
StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+ StatusRegistry::register( StatusRegistry::KIND_PLAN, 'seasonal' );
StatusRegistry::reset();
$this->assertSame( ContractStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT ) );
$this->assertSame( CycleStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CYCLE ) );
+ $this->assertSame( PlanStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_PLAN ) );
$this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_PLAN, 'seasonal' ) );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
index aefca28998b..e94c41df699 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
@@ -40,6 +40,15 @@ class CoercionTest extends TestCase {
$this->assertSame( array(), Coercion::coerce_string_keyed( array() ) );
}
+ public function test_coerce_nullable_string_keyed_keeps_an_array_and_nulls_anything_else(): void {
+ // PHP stores an integer-like string key as an int, so a list stays a list.
+ $this->assertSame( array( 'a' ), Coercion::coerce_nullable_string_keyed( array( 'a' ) ) );
+ $this->assertSame( array( 'period' => 'month' ), Coercion::coerce_nullable_string_keyed( array( 'period' => 'month' ) ) );
+ $this->assertSame( array(), Coercion::coerce_nullable_string_keyed( array() ) );
+ $this->assertNull( Coercion::coerce_nullable_string_keyed( null ) );
+ $this->assertNull( Coercion::coerce_nullable_string_keyed( 'monthly' ) );
+ }
+
/**
* @dataProvider provide_non_arrays
*
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
index f087a5a534e..2ca3627a253 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
@@ -20,6 +20,43 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
*/
class BillingPolicyTest extends TestCase {
+ /**
+ * @testdox from_array refuses an unusable cadence.
+ * @testWith ["decade", 1, "BillingPolicy: invalid period \"decade\"."]
+ * ["month", 0, "BillingPolicy: interval must be positive, got 0."]
+ * ["day", -1, "BillingPolicy: interval must be positive, got -1."]
+ *
+ * @param string $period Period.
+ * @param int $interval Interval.
+ * @param string $message Expected exception message.
+ */
+ public function test_from_array_refuses_an_unusable_cadence( string $period, int $interval, string $message ): void {
+ $data = array(
+ 'period' => $period,
+ 'interval' => $interval,
+ );
+
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( $message );
+
+ BillingPolicy::from_array( $data );
+ }
+
+ /**
+ * @testdox from_array accepts a usable cadence.
+ */
+ public function test_from_array_accepts_a_usable_cadence(): void {
+ $policy = BillingPolicy::from_array(
+ array(
+ 'period' => 'week',
+ 'interval' => 2,
+ )
+ );
+
+ $this->assertSame( 'week', $policy->get_period() );
+ $this->assertSame( 2, $policy->get_interval() );
+ }
+
public function test_round_trips_through_array(): void {
$data = array(
'period' => 'month',
@@ -104,28 +141,22 @@ class BillingPolicyTest extends TestCase {
);
}
- public function test_invalid_period_throws(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'fortnight',
- 'interval' => 1,
- )
- );
-
+ /**
+ * @testdox construction refuses an unknown period.
+ */
+ public function test_construction_refuses_an_unknown_period(): void {
$this->expectException( DomainException::class );
- $policy->compute_next_renewal_from( new DateTimeImmutable( '2026-01-01', new DateTimeZone( 'UTC' ) ) );
- }
- public function test_non_positive_interval_throws(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 0,
- )
- );
+ new BillingPolicy( 'fortnight', 1, null, null, null );
+ }
+ /**
+ * @testdox construction refuses a non-positive interval.
+ */
+ public function test_construction_refuses_a_non_positive_interval(): void {
$this->expectException( DomainException::class );
- $policy->compute_next_renewal_from( new DateTimeImmutable( '2026-01-01', new DateTimeZone( 'UTC' ) ) );
+
+ new BillingPolicy( 'month', 0, null, null, null );
}
public function test_non_array_trial_duration_throws(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
index 231d29b2d59..3f727a58230 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -97,9 +97,9 @@ class PlanSnapshotTest extends TestCase {
}
/**
- * @testdox get_billing_policy reconstructs the frozen cadence from the payload.
+ * @testdox read_billing_policy reconstructs the frozen cadence from the payload.
*/
- public function test_get_billing_policy_reconstructs_the_frozen_cadence(): void {
+ public function test_read_billing_policy_reconstructs_the_frozen_cadence(): void {
$snapshot = PlanSnapshot::from_array(
array(
'selling_plan_id' => 7,
@@ -114,7 +114,7 @@ class PlanSnapshotTest extends TestCase {
)
);
- $policy = $snapshot->get_billing_policy();
+ $policy = $snapshot->read_billing_policy();
$this->assertInstanceOf( BillingPolicy::class, $policy );
$this->assertSame( 'month', $policy->get_period() );
@@ -123,27 +123,51 @@ class PlanSnapshotTest extends TestCase {
}
/**
- * @testdox get_billing_policy is null when the payload carries no billing policy.
+ * @testdox read_billing_policy throws for a structurally-invalid stored policy.
*/
- public function test_get_billing_policy_is_null_when_absent(): void {
- $snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+ public function test_read_billing_policy_throws_for_an_unreadable_policy(): void {
+ // `interval` missing: BillingPolicy::from_array() refuses it.
+ $snapshot = PlanSnapshot::from_array(
+ array(
+ 'billing_policy' => array( 'period' => 'month' ),
+ )
+ );
- $this->assertNull( $snapshot->get_billing_policy() );
+ $this->expectException( DomainException::class );
+ $snapshot->read_billing_policy();
}
/**
- * @testdox get_billing_policy degrades to null for a structurally-invalid stored policy.
+ * @testdox read_billing_policy throws for a policy with no usable cadence ($label).
+ *
+ * @testWith ["unknown period", "decade", 1]
+ * ["zero interval", "month", 0]
+ * ["negative interval", "week", -2]
+ *
+ * @param string $label Case label.
+ * @param string $period Stored period.
+ * @param int $interval Stored interval.
*/
- public function test_get_billing_policy_is_null_for_an_unreadable_policy(): void {
- // `interval` missing: BillingPolicy::from_array() would throw; the accessor swallows
- // it and degrades to "no cadence" rather than fataling the read.
+ public function test_a_policy_without_a_usable_cadence_is_not_read( string $label, string $period, int $interval ): void {
+ unset( $label );
$snapshot = PlanSnapshot::from_array(
array(
- 'billing_policy' => array( 'period' => 'month' ),
+ 'billing_policy' => array(
+ 'period' => $period,
+ 'interval' => $interval,
+ ),
)
);
- $this->assertNull( $snapshot->get_billing_policy() );
+ $this->expectException( DomainException::class );
+ $snapshot->read_billing_policy();
+ }
+
+ /**
+ * @testdox read_billing_policy is null when the payload carries no billing policy.
+ */
+ public function test_read_billing_policy_is_null_when_absent(): void {
+ $this->assertNull( PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) )->read_billing_policy() );
}
/**