Commit f755844ec96 for woocommerce
commit f755844ec96c83ab4ec291c074c61bd296193e81
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Fri Oct 9 17:46:17 2026 +0200
Subscriptions engine: move lifecycle flows out and add a contract action endpoint (#69584)
Subscriptions engine: move lifecycle flows out and add a contract action endpoint (#69584)
Hold, reactivate and cancel are lifecycle policy, so the engine stops running
them; the consuming extension implements them on Api\Contracts. Instead of each
extension shipping its own contract endpoints, the engine offers one action
endpoint that dispatches extension-registered actions.
Added:
- Api\ContractActions::register( $extension_slug, $action, $args ): callback,
permission (a capability checked with the ContractView, or a callable),
description, args (property schemas or a per-contract callable) and
is_available.
- read_subscription_contract and manage_subscription_contract meta
capabilities, mapped at map_meta_cap priority 0: the contract's customer
needs read, anyone else manage_woocommerce.
- GET wc/v3/subscriptions-engine/contracts/{id}: the stored contract.
- GET .../contracts/{id}/action: the owner's available actions.
- POST .../contracts/{id}/action { action, extension_slug, action_args }:
runs the owner's action. 401 logged out; one 404 for unknown, other-owner,
unregistered or not permitted; 409 not available; 400 invalid action_args.
Removed:
- Integration\Contracts\Hold, Reactivation, Cancellation and their actions.
- The contracts/{id}/(hold|reactivate|cancel) routes.
- Api\Subscriptions::cancel(), hold(), reactivate(), cancel_at_period_end().
Kept: the renewal path and its status guards.
Part of WOOSUBS-2062
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-lifecycle-flows-to-lite b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-lifecycle-flows-to-lite
new file mode 100644
index 00000000000..baa4ecc1308
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-lifecycle-flows-to-lite
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move lifecycle flows to extensions and add a contract action endpoint in the unreleased subscriptions engine; no changelog entry needed.
+
diff --git a/packages/php/woocommerce-subscriptions-engine/phpcs.xml b/packages/php/woocommerce-subscriptions-engine/phpcs.xml
index 22a3255832d..37162912c90 100644
--- a/packages/php/woocommerce-subscriptions-engine/phpcs.xml
+++ b/packages/php/woocommerce-subscriptions-engine/phpcs.xml
@@ -3,6 +3,16 @@
<!-- Set the base standard to WordPress -->
<rule ref="WordPress"/>
+ <!-- The engine maps these meta capabilities (Integration\Ownership\ContractCapabilities). -->
+ <rule ref="WordPress.WP.Capabilities">
+ <properties>
+ <property name="custom_capabilities" type="array">
+ <element value="read_subscription_contract"/>
+ <element value="manage_subscription_contract"/>
+ </property>
+ </properties>
+ </rule>
+
<!-- Define files and folders to scan -->
<file>.</file>
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
new file mode 100644
index 00000000000..024523dec4e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * ContractActions - register the contract actions the engine's action endpoint dispatches.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public registration facade for contract actions.
+ *
+ * `wc/v3/subscriptions-engine/contracts/{id}/action` dispatches only to actions registered by
+ * the contract's owning extension. The engine registers no actions. Register on or before `rest_api_init`. Final and static-only.
+ */
+final class ContractActions {
+
+ /**
+ * Keys accepted by {@see self::register()}, as a key map.
+ *
+ * @var array<string, true>
+ */
+ private const REGISTRATION_KEYS = array(
+ 'callback' => true,
+ 'permission' => true,
+ 'description' => true,
+ 'args' => true,
+ 'is_available' => true,
+ );
+
+ /**
+ * Register an action for the extension's contracts.
+ *
+ * An invalid or duplicate registration raises a `_doing_it_wrong()` notice and is not
+ * registered (the first registration is kept). Unknown keys raise a notice and are ignored.
+ *
+ * @param string $extension_slug Owning extension slug; matches the contracts' `extension_slug`.
+ * @param string $action Action slug: lowercase letters, numbers, hyphens and underscores.
+ * @param array<string, mixed> $args `callback` (required): `callable( ContractView $contract, array $action_args ): ContractView|WP_Error`.
+ * `permission` (required): a capability, checked as `current_user_can( $permission, $contract )`
+ * with the `ContractView`, or a non-string `callable( ContractView $contract, WP_REST_Request $request ): bool`.
+ * `manage_subscription_contract` allows the contract's customer and store managers.
+ * `description` (string, default '').
+ * `args`: REST property schemas for `action_args` (`array<string, array>`),
+ * or `callable( ContractView $contract ): array` resolved per request.
+ * `is_available`: `callable( ContractView $contract ): bool`, default always.
+ */
+ public static function register( string $extension_slug, string $action, array $args ): void {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::REGISTRATION_KEYS, 'argument' );
+ $callback = $filtered_args['callback'] ?? null;
+ $permission = $filtered_args['permission'] ?? null;
+ $description = $filtered_args['description'] ?? '';
+ $action_args = $filtered_args['args'] ?? array();
+ $is_available = $filtered_args['is_available'] ?? null;
+
+ if ( '' === trim( $extension_slug ) ) {
+ self::reject( 'The extension slug must not be empty.' );
+ return;
+ }
+
+ if ( '' === $action || sanitize_key( $action ) !== $action ) {
+ self::reject( sprintf( 'Contract action "%s" may only contain lowercase letters, numbers, hyphens and underscores.', $action ) );
+ return;
+ }
+
+ if ( ! is_callable( $callback ) ) {
+ self::reject( sprintf( 'Contract action "%s" needs a callable "callback".', $action ) );
+ return;
+ }
+
+ if ( is_string( $permission ) ? '' === trim( $permission ) : ! is_callable( $permission ) ) {
+ self::reject( sprintf( 'Contract action "%s" needs a "permission" capability or callable.', $action ) );
+ return;
+ }
+
+ if ( ! is_string( $description ) ) {
+ self::reject( sprintf( 'The "description" of contract action "%s" must be a string.', $action ) );
+ return;
+ }
+
+ if ( ! is_callable( $action_args ) && ! ContractActionRegistry::is_args_schema( $action_args ) ) {
+ self::reject( sprintf( 'The "args" of contract action "%s" must be an array of property schemas or a callable.', $action ) );
+ return;
+ }
+
+ if ( null !== $is_available && ! is_callable( $is_available ) ) {
+ self::reject( sprintf( 'The "is_available" of contract action "%s" must be a callable.', $action ) );
+ return;
+ }
+
+ if ( ContractActionRegistry::has( $extension_slug, $action ) ) {
+ self::reject( sprintf( 'Contract action "%s" is already registered for "%s"; the first registration is kept.', $action, $extension_slug ) );
+ return;
+ }
+
+ ContractActionRegistry::add(
+ array(
+ 'extension_slug' => $extension_slug,
+ 'action' => $action,
+ 'callback' => $callback,
+ 'permission' => $permission,
+ 'description' => $description,
+ 'args' => $action_args,
+ 'is_available' => $is_available,
+ )
+ );
+ }
+
+ /**
+ * Raise the `_doing_it_wrong()` notice for a registration that is not registered.
+ *
+ * @param string $message What is wrong.
+ */
+ private static function reject( string $message ): void {
+ _doing_it_wrong( __CLASS__ . '::register', esc_html( $message ), '0.0.1' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
index 667d2801fbc..fd91206ecbd 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -11,13 +11,12 @@
* 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()}
+ * contract carries a plan snapshot, renewal falls back to the live plan's
+ * `billing_policy` and reads 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.
+ * renewal parks such a contract. 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
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
index 1b3f84a85ef..684df422e8c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -1,35 +1,6 @@
<?php
/**
- * ContractsController - the authenticated `wc/v3` REST surface for the generic
- * contract lifecycle actions.
- *
- * Routes (namespace `wc/v3`, base `subscriptions-engine/contracts`):
- *
- * POST /{id}/hold Put an active contract on hold.
- * POST /{id}/reactivate Resume a held contract (next date recomputed forward).
- * POST /{id}/cancel Cancel. Body `{ at_period_end: bool }` - true winds the
- * contract down at the current period end, false cancels now.
- *
- * Actions only, deliberately: the engine exposes ONE implementation of the lifecycle
- * transitions (guards, ownership, conflict semantics) that every consumer calls rather
- * than re-implements - while READS for UI stay server-side, where each consumer shapes
- * its own view from the {@see Subscriptions} facade. There are no read routes here and
- * no view-model in the responses: an action responds with a minimal domain summary
- * (`id` + resulting `status` slug, e.g. cancel lands on `pending-cancellation` or
- * `cancelled` depending on the mode), so no consumer-specific presentation leaks into
- * the engine. The summary is ADDITIVE: fields may appear; consumers tolerate unknown
- * fields and must not assume the set is closed. A generic resource read API is a
- * planned follow-up alongside the read-model views, when a consumer needs it.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * Every route requires a logged-in user, enforced through the shared
- * {@see RESTPermissions} floor (core's cookie auth has already verified the REST nonce
- * `wp_rest` by then). Per-route, ownership is enforced with the asymmetric not-found
- * rule: a contract owned by another user returns 404 - IDENTICAL to an unknown id - so
- * a caller never confirms the existence of a contract the requester does not own
- * (anti-IDOR).
+ * REST controller for subscription engine contracts.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api\Rest
*/
@@ -38,23 +9,29 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
-use DomainException;
+use SplObjectStorage;
use Throwable;
+use UnexpectedValueException;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
defined( 'ABSPATH' ) || exit;
/**
- * REST controller for the generic contract lifecycle actions.
+ * Contracts REST controller under `wc/v3/subscriptions-engine/contracts`: `GET /{id}` returns
+ * the stored contract to store managers; `GET|POST /{id}/action` lists and runs the actions
+ * the contract's owning extension registered through {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions}.
+ *
+ * @phpstan-import-type ContractActionDefinition from ContractActionRegistry
*/
final class ContractsController extends WP_REST_Controller {
@@ -62,6 +39,8 @@ final class ContractsController extends WP_REST_Controller {
private const REST_BASE = 'subscriptions-engine/contracts';
+ private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
/**
* REST permissions.
*
@@ -70,14 +49,28 @@ final class ContractsController extends WP_REST_Controller {
private $rest_permissions;
/**
- * Build the controller.
+ * Read resolutions per request, so the callback reuses what the permission check read.
+ *
+ * @var SplObjectStorage<WP_REST_Request, ContractView|WP_Error>
+ */
+ private $resolved_reads;
+
+ /**
+ * Run resolutions per request, so the callback reuses what the permission check read.
*
- * @param RESTPermissions|null $rest_permissions REST permissions; default instance when omitted.
+ * @var SplObjectStorage<WP_REST_Request, array{contract: ContractView, definition: ContractActionDefinition}|WP_Error>
+ */
+ private $resolved_runs;
+
+ /**
+ * Build the controller.
*/
- public function __construct( ?RESTPermissions $rest_permissions = null ) {
+ public function __construct() {
$this->namespace = self::REST_NAMESPACE;
$this->rest_base = self::REST_BASE;
- $this->rest_permissions = $rest_permissions ?? new RESTPermissions();
+ $this->rest_permissions = new RESTPermissions();
+ $this->resolved_reads = new SplObjectStorage();
+ $this->resolved_runs = new SplObjectStorage();
}
/**
@@ -98,13 +91,21 @@ final class ContractsController extends WP_REST_Controller {
public function register_routes(): void {
register_rest_route(
self::REST_NAMESPACE,
- '/' . self::REST_BASE . '/(?P<id>[\d]+)/hold',
+ '/' . self::REST_BASE . '/(?P<id>[\d]+)',
array(
- 'args' => $this->id_arg(),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the contract.', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'integer',
+ ),
+ ),
array(
- 'methods' => WP_REST_Server::CREATABLE,
- 'callback' => array( $this, 'hold_item' ),
- 'permission_callback' => array( $this, 'permissions_check' ),
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
@@ -112,137 +113,230 @@ final class ContractsController extends WP_REST_Controller {
register_rest_route(
self::REST_NAMESPACE,
- '/' . self::REST_BASE . '/(?P<id>[\d]+)/reactivate',
+ '/' . self::REST_BASE . '/(?P<id>[\d]+)/action',
array(
- 'args' => $this->id_arg(),
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the contract.', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'integer',
+ ),
+ ),
array(
- 'methods' => WP_REST_Server::CREATABLE,
- 'callback' => array( $this, 'reactivate_item' ),
- 'permission_callback' => array( $this, 'permissions_check' ),
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_actions' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
- 'schema' => array( $this, 'get_public_item_schema' ),
- )
- );
-
- register_rest_route(
- self::REST_NAMESPACE,
- '/' . self::REST_BASE . '/(?P<id>[\d]+)/cancel',
- array(
- 'args' => $this->id_arg(),
array(
'methods' => WP_REST_Server::CREATABLE,
- 'callback' => array( $this, 'cancel_item' ),
- 'permission_callback' => array( $this, 'permissions_check' ),
+ 'callback' => array( $this, 'run_action' ),
+ 'permission_callback' => array( $this, 'run_action_permissions_check' ),
'args' => array(
- 'at_period_end' => array(
- 'description' => __( 'Whether to cancel at the end of the current billing period (true) or immediately (false).', 'woocommerce-subscriptions-engine' ),
- 'type' => 'boolean',
- 'required' => false,
- 'default' => true,
- 'sanitize_callback' => 'rest_sanitize_boolean',
- 'validate_callback' => 'rest_validate_request_arg',
+ 'action' => array(
+ 'description' => __( 'Action to run.', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'extension_slug' => array(
+ 'description' => __( 'Slug of the extension that owns the contract and registered the action.', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'action_args' => array(
+ 'description' => __( 'Arguments for the action, as its schema describes them.', 'woocommerce-subscriptions-engine' ),
+ 'type' => 'object',
+ 'default' => array(),
),
),
),
- 'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
- * Permission callback for all routes: the shared logged-in floor.
- *
- * Any logged-in user passes; per-contract ownership is enforced by the route
- * handlers (the asymmetric 404).
+ * Check whether the current user may read the contract. Anything that is not a 401 is the
+ * same 404, so a caller cannot probe for contracts it may not read.
*
* @param WP_REST_Request $request Request.
- * @return true|WP_Error True when logged in, else a 401 error.
+ * @return true|WP_Error
*/
- public function permissions_check( $request ) {
- return $this->rest_permissions->require_logged_in_permission();
+ public function get_item_permissions_check( $request ) {
+ $logged_in = $this->rest_permissions->require_logged_in_permission();
+ if ( true !== $logged_in ) {
+ return $logged_in;
+ }
+
+ $resolved = $this->resolve_read( $request );
+
+ return $resolved instanceof WP_Error ? $resolved : true;
}
/**
- * POST /{id}/hold.
+ * Check whether the current user may run the requested action on the contract. Anything
+ * that is not a 401 is the same 404, so a caller cannot probe for contracts it may not act on.
*
- * @param WP_REST_Request $request The request.
- * @return WP_REST_Response|WP_Error The domain summary, or an error.
+ * @param WP_REST_Request $request Request.
+ * @return true|WP_Error
*/
- public function hold_item( $request ) {
- return $this->run_action(
- $request,
- static function ( int $id ): void {
- Subscriptions::hold( $id );
- }
- );
+ public function run_action_permissions_check( $request ) {
+ $logged_in = $this->rest_permissions->require_logged_in_permission();
+ if ( true !== $logged_in ) {
+ return $logged_in;
+ }
+
+ $resolved = $this->resolve_run( $request );
+
+ return $resolved instanceof WP_Error ? $resolved : true;
}
/**
- * POST /{id}/reactivate.
+ * List the actions the contract's owner registered that are available for it now, with their
+ * resolved args.
*
- * @param WP_REST_Request $request The request.
- * @return WP_REST_Response|WP_Error The domain summary, or an error.
+ * @param WP_REST_Request $request Request.
+ * @return WP_REST_Response|WP_Error
*/
- public function reactivate_item( $request ) {
- return $this->run_action(
- $request,
- static function ( int $id ): void {
- Subscriptions::reactivate( $id );
+ public function get_actions( $request ) {
+ $contract = $this->resolve_read( $request );
+ if ( $contract instanceof WP_Error ) {
+ return $contract;
+ }
+
+ $actions = array();
+ try {
+ foreach ( ContractActionRegistry::get_for_extension( (string) $contract->get_extension_slug() ) as $definition ) {
+ if ( ! ContractActionRegistry::is_available( $definition, $contract ) ) {
+ continue;
+ }
+
+ $actions[] = array(
+ 'action' => $definition['action'],
+ 'extension_slug' => $definition['extension_slug'],
+ 'description' => $definition['description'],
+ 'args' => $this->get_args_for_response( ContractActionRegistry::get_args_schema( $definition, $contract ) ),
+ );
}
- );
+ } catch ( Throwable $e ) {
+ return $this->get_action_failed_error( $e, $request );
+ }
+
+ return rest_ensure_response( array( 'actions' => $actions ) );
}
/**
- * POST /{id}/cancel - body `{ at_period_end: bool }` (default true).
+ * Run the action: 409 when it is not available, 400 for invalid `action_args`, then the
+ * callback's result. A `WP_Error` passes through (status 400 unless it has one).
*
- * `at_period_end` true winds the contract down at the current period end (graceful);
- * false cancels immediately, reusing the shared {@see Subscriptions::cancel()}.
- *
- * @param WP_REST_Request $request The request.
- * @return WP_REST_Response|WP_Error The domain summary, or an error.
+ * @param WP_REST_Request $request Request.
+ * @return WP_REST_Response|WP_Error
*/
- public function cancel_item( $request ) {
- // Boolean-typed, defaulted, and `rest_sanitize_boolean`-sanitized by the route
- // schema, so it arrives as a real bool; the coercion path covers a caller
- // invoking the method directly with a raw value.
- $param = $request->get_param( 'at_period_end' );
- $at_period_end = is_bool( $param ) ? $param : rest_sanitize_boolean( Coercion::coerce_string( $param, 'true' ) );
-
- return $this->run_action(
- $request,
- static function ( int $id ) use ( $at_period_end ): void {
- if ( $at_period_end ) {
- Subscriptions::cancel_at_period_end( $id );
- } else {
- Subscriptions::cancel( $id );
- }
+ public function run_action( $request ) {
+ $resolved = $this->resolve_run( $request );
+ if ( $resolved instanceof WP_Error ) {
+ return $resolved;
+ }
+
+ $contract = $resolved['contract'];
+ $definition = $resolved['definition'];
+ try {
+ if ( ! ContractActionRegistry::is_available( $definition, $contract ) ) {
+ return new WP_Error(
+ 'woocommerce_subscriptions_engine_action_not_available',
+ __( 'This action is not available for the contract.', 'woocommerce-subscriptions-engine' ),
+ array( 'status' => 409 )
+ );
}
- );
+
+ $action_args = $this->get_validated_action_args( $request, ContractActionRegistry::get_args_schema( $definition, $contract ) );
+ if ( $action_args instanceof WP_Error ) {
+ return $action_args;
+ }
+
+ $result = ( $definition['callback'] )( $contract, $action_args );
+ } catch ( Throwable $e ) {
+ return $this->get_action_failed_error( $e, $request );
+ }
+
+ if ( $result instanceof ContractView ) {
+ return rest_ensure_response(
+ array(
+ 'id' => $result->get_id(),
+ 'status' => $result->get_status(),
+ )
+ );
+ }
+
+ if ( $result instanceof WP_Error ) {
+ $error_data = $result->get_error_data();
+ if ( ! is_array( $error_data ) || ! isset( $error_data['status'] ) ) {
+ $result->add_data( array_merge( is_array( $error_data ) ? $error_data : array(), array( 'status' => 400 ) ) );
+ }
+
+ return $result;
+ }
+
+ return $this->get_action_failed_error( new UnexpectedValueException( 'The action callback must return a ContractView or a WP_Error.' ), $request );
}
/**
- * Serialize a contract as the action-response domain summary.
+ * Get one contract with its items and addresses.
*
- * Domain values only - the id and the resulting status slug - never labels,
- * formatted values, or other presentation: consumers own their view shaping.
+ * @param WP_REST_Request $request Request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_item( $request ) {
+ $contract = $this->resolve_read( $request );
+ if ( $contract instanceof WP_Error ) {
+ return $contract;
+ }
+
+ return $this->prepare_item_for_response( $contract, $request );
+ }
+
+ /**
+ * The contract as response data. Dates are GMT, formatted like other WordPress REST dates.
*
- * @param ContractView $item Contract view.
+ * @param ContractView $item Contract.
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $item, $request ) {
$data = array(
- 'id' => (int) $item->get_id(),
- 'status' => $item->get_status(),
+ 'id' => $item->get_id(),
+ 'extension_slug' => $item->get_extension_slug(),
+ 'status' => $item->get_status(),
+ 'customer_id' => $item->get_customer_id(),
+ 'currency' => $item->get_currency(),
+ 'selling_plan_id' => $item->get_selling_plan_id(),
+ 'origin_order_id' => $item->get_origin_order_id(),
+ 'payment_method' => $item->get_payment_method(),
+ 'payment_method_title' => $item->get_payment_method_title(),
+ 'payment_token_id' => $item->get_payment_token_id(),
+ 'start_gmt' => wc_rest_prepare_date_response( $item->get_start_gmt() ),
+ 'next_payment_gmt' => wc_rest_prepare_date_response( $item->get_next_payment_gmt() ),
+ 'last_payment_gmt' => wc_rest_prepare_date_response( $item->get_last_payment_gmt() ),
+ 'last_attempt_gmt' => wc_rest_prepare_date_response( $item->get_last_attempt_gmt() ),
+ 'trial_end_gmt' => wc_rest_prepare_date_response( $item->get_trial_end_gmt() ),
+ 'end_gmt' => wc_rest_prepare_date_response( $item->get_end_gmt() ),
+ 'schedule_source' => $item->get_schedule_source(),
+ 'billing_total' => $item->get_billing_total(),
+ 'discount_total' => $item->get_discount_total(),
+ 'shipping_total' => $item->get_shipping_total(),
+ 'tax_total' => $item->get_tax_total(),
+ 'items' => $item->get_items() ?? array(),
+ 'addresses' => $item->get_addresses() ?? array(),
);
+ if ( array() === $data['addresses'] ) {
+ $data['addresses'] = new \stdClass(); // Encodes as `{}`.
+ }
$data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, Coercion::coerce_string( $request->get_param( 'context' ), 'view' ) );
return rest_ensure_response( $data );
}
/**
- * Get item schema: the action-response domain summary.
+ * Get the contract schema.
*
* @return array<string, mixed>
*/
@@ -251,106 +345,216 @@ final class ContractsController extends WP_REST_Controller {
return $this->add_additional_fields_schema( $this->schema );
}
+ $properties = array(
+ 'id' => array( 'integer', __( 'Unique identifier for the contract.', 'woocommerce-subscriptions-engine' ) ),
+ 'extension_slug' => array( array( 'string', 'null' ), __( 'Slug of the extension that owns the contract.', 'woocommerce-subscriptions-engine' ) ),
+ 'status' => array( 'string', __( 'Contract status slug.', 'woocommerce-subscriptions-engine' ) ),
+ 'customer_id' => array( array( 'integer', 'null' ), __( 'Customer user ID.', 'woocommerce-subscriptions-engine' ) ),
+ 'currency' => array( array( 'string', 'null' ), __( 'Currency code.', 'woocommerce-subscriptions-engine' ) ),
+ 'selling_plan_id' => array( array( 'integer', 'null' ), __( 'Plan ID.', 'woocommerce-subscriptions-engine' ) ),
+ 'origin_order_id' => array( array( 'integer', 'null' ), __( 'ID of the order the contract started from.', 'woocommerce-subscriptions-engine' ) ),
+ 'payment_method' => array( array( 'string', 'null' ), __( 'Payment gateway ID.', 'woocommerce-subscriptions-engine' ) ),
+ 'payment_method_title' => array( array( 'string', 'null' ), __( 'Payment method title.', 'woocommerce-subscriptions-engine' ) ),
+ 'payment_token_id' => array( array( 'integer', 'null' ), __( 'Payment token ID.', 'woocommerce-subscriptions-engine' ) ),
+ 'start_gmt' => array( array( 'string', 'null' ), __( 'Start date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+ 'next_payment_gmt' => array( array( 'string', 'null' ), __( 'Next-due moment, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+ 'last_payment_gmt' => array( array( 'string', 'null' ), __( 'Last payment date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+ 'last_attempt_gmt' => array( array( 'string', 'null' ), __( 'Last payment attempt date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+ 'trial_end_gmt' => array( array( 'string', 'null' ), __( 'Trial end date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+ 'end_gmt' => array( array( 'string', 'null' ), __( 'End date, as GMT.', 'woocommerce-subscriptions-engine' ) ),
+ 'schedule_source' => array( 'string', __( 'Who keeps the payment schedule.', 'woocommerce-subscriptions-engine' ) ),
+ 'billing_total' => array( 'string', __( 'Recurring total.', 'woocommerce-subscriptions-engine' ) ),
+ 'discount_total' => array( 'string', __( 'Recurring discount total.', 'woocommerce-subscriptions-engine' ) ),
+ 'shipping_total' => array( 'string', __( 'Recurring shipping total.', 'woocommerce-subscriptions-engine' ) ),
+ 'tax_total' => array( 'string', __( 'Recurring tax total.', 'woocommerce-subscriptions-engine' ) ),
+ 'items' => array( 'array', __( 'Line items.', 'woocommerce-subscriptions-engine' ) ),
+ 'addresses' => array( 'object', __( 'Billing and shipping addresses.', 'woocommerce-subscriptions-engine' ) ),
+ );
+
+ $schema_properties = array();
+ foreach ( $properties as $key => $property ) {
+ $schema_properties[ $key ] = array(
+ 'description' => $property[1],
+ 'type' => $property[0],
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ );
+ }
+
$this->schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
- 'title' => 'subscription_engine_contract_action',
+ 'title' => 'subscription_engine_contract',
'type' => 'object',
- 'properties' => array(
- 'id' => array(
- 'description' => __( 'Unique identifier for the subscription contract.', 'woocommerce-subscriptions-engine' ),
- 'type' => 'integer',
- 'context' => array( 'view' ),
- 'readonly' => true,
- ),
- 'status' => array(
- 'description' => __( 'Contract status after the action.', 'woocommerce-subscriptions-engine' ),
- 'type' => 'string',
- 'context' => array( 'view' ),
- 'readonly' => true,
- ),
- ),
+ 'properties' => $schema_properties,
);
return $this->add_additional_fields_schema( $this->schema );
}
/**
- * Run a lifecycle action behind the ownership guard, then return the domain
- * summary with the resulting status.
+ * The contract, once the current user may read it (`read_subscription_contract`); resolved
+ * once per request. Unknown and unreadable contracts are the same 404.
*
- * A `DomainException` (an action whose preconditions the contract's current state does not meet) maps to
- * a 409 Conflict; any other failure maps to a 500. The ownership guard keeps the
- * asymmetric 404 for not-owned / unknown.
- *
- * @param WP_REST_Request $request The request (carries the id).
- * @param callable $action Runs the lifecycle action; receives the contract id.
- * @return WP_REST_Response|WP_Error
+ * @param WP_REST_Request $request Request.
+ * @return ContractView|WP_Error
*/
- private function run_action( WP_REST_Request $request, callable $action ) {
- $contract_id = Coercion::coerce_int( $request->get_param( 'id' ) );
- $customer_id = get_current_user_id();
+ private function resolve_read( WP_REST_Request $request ) {
+ if ( ! isset( $this->resolved_reads[ $request ] ) ) {
+ $contract = Contracts::get( Coercion::coerce_int( $request->get_param( 'id' ) ) );
- // Guard ownership before acting: the facade's ownership-checked read returns
- // null for an unknown id and a foreign-owned contract alike, so both map to
- // the same 404 (anti-IDOR).
- if ( null === Contracts::get_for_customer( $contract_id, $customer_id ) ) {
- return $this->not_found_error();
+ $this->resolved_reads[ $request ] = null !== $contract && current_user_can( ContractCapabilities::READ, $contract )
+ ? $contract
+ : $this->get_not_found_error();
}
- try {
- $action( $contract_id );
- } catch ( DomainException $e ) {
- return new WP_Error(
- 'woocommerce_subscriptions_engine_illegal_action',
- __( 'That action is not available for this subscription right now.', 'woocommerce-subscriptions-engine' ),
- array( 'status' => 409 )
- );
- } catch ( Throwable $e ) {
- return new WP_Error(
- 'woocommerce_subscriptions_engine_action_failed',
- __( 'The subscription could not be updated. Please try again.', 'woocommerce-subscriptions-engine' ),
- array( 'status' => 500 )
- );
+ return $this->resolved_reads[ $request ];
+ }
+
+ /**
+ * The contract and the requested action, once the current user is permitted to run it;
+ * resolved once per request. Unknown contract, wrong `extension_slug`, unknown action and
+ * no permission are the same 404.
+ *
+ * @param WP_REST_Request $request Request.
+ * @return array{contract: ContractView, definition: ContractActionDefinition}|WP_Error
+ */
+ private function resolve_run( WP_REST_Request $request ) {
+ if ( ! isset( $this->resolved_runs[ $request ] ) ) {
+ try {
+ $this->resolved_runs[ $request ] = $this->get_permitted_run( $request );
+ } catch ( Throwable $e ) {
+ $this->resolved_runs[ $request ] = $this->get_action_failed_error( $e, $request );
+ }
}
- // Re-read for the resulting status. The action already succeeded, so a row
- // vanishing here is a server-side inconsistency - a 500, not a not-found.
- $refreshed = Contracts::get_for_customer( $contract_id, $customer_id );
- if ( null === $refreshed ) {
- return new WP_Error(
- 'woocommerce_subscriptions_engine_refresh_failed',
- __( 'The subscription was updated, but its refreshed state could not be loaded.', 'woocommerce-subscriptions-engine' ),
- array( 'status' => 500 )
- );
+ return $this->resolved_runs[ $request ];
+ }
+
+ /**
+ * Read the contract and the requested action of its owner, and check the user may run it.
+ *
+ * @param WP_REST_Request $request Request.
+ * @return array{contract: ContractView, definition: ContractActionDefinition}|WP_Error
+ */
+ private function get_permitted_run( WP_REST_Request $request ) {
+ $contract = Contracts::get( Coercion::coerce_int( $request->get_param( 'id' ) ) );
+ $extension_slug = $request->get_param( 'extension_slug' );
+ $action = $request->get_param( 'action' );
+ if ( null === $contract || ! is_string( $extension_slug ) || $contract->get_extension_slug() !== $extension_slug || ! is_string( $action ) ) {
+ return $this->get_not_found_error();
}
- return $this->prepare_item_for_response( $refreshed, $request );
+ $definition = ContractActionRegistry::get( $extension_slug, $action );
+ if ( null === $definition || ! ContractActionRegistry::is_permitted( $definition, $contract, $request ) ) {
+ return $this->get_not_found_error();
+ }
+
+ return array(
+ 'contract' => $contract,
+ 'definition' => $definition,
+ );
}
/**
- * The shared 404, identical for unknown and not-owned contracts.
+ * The 404 for an unknown contract, also returned for a contract or action the caller may not see.
*/
- private function not_found_error(): WP_Error {
+ private function get_not_found_error(): WP_Error {
return new WP_Error(
'woocommerce_subscriptions_engine_contract_not_found',
- __( 'Subscription not found.', 'woocommerce-subscriptions-engine' ),
+ __( 'Contract not found.', 'woocommerce-subscriptions-engine' ),
array( 'status' => 404 )
);
}
/**
- * Route-level arg schema for the `{id}` path parameter.
+ * Validate `action_args` against the action's property schemas: defaults filled in, unknown
+ * keys dropped, a 400 when a value does not match.
*
- * @return array<string, mixed>
+ * @param WP_REST_Request $request Request.
+ * @param array<string, array<string, mixed>> $properties Property schemas.
+ * @return array<string, mixed>|WP_Error
*/
- private function id_arg(): array {
- return array(
- 'id' => array(
- 'description' => __( 'Unique identifier for the subscription contract.', 'woocommerce-subscriptions-engine' ),
- 'type' => 'integer',
- 'sanitize_callback' => 'absint',
- 'validate_callback' => 'rest_validate_request_arg',
- ),
+ private function get_validated_action_args( WP_REST_Request $request, array $properties ) {
+ $defaults = array();
+ foreach ( $properties as $name => $property ) {
+ if ( array_key_exists( 'default', $property ) ) {
+ $defaults[ $name ] = $property['default'];
+ }
+ }
+
+ $request_args = $request->get_param( 'action_args' );
+ $action_args = ( is_array( $request_args ) ? $request_args : array() ) + $defaults;
+ $schema = array(
+ 'type' => 'object',
+ 'properties' => $properties,
+ );
+
+ $valid = rest_validate_value_from_schema( $action_args, $schema, 'action_args' );
+ if ( $valid instanceof WP_Error ) {
+ return $this->get_invalid_action_args_error( $valid );
+ }
+
+ $sanitized = rest_sanitize_value_from_schema( $action_args, $schema + array( 'additionalProperties' => false ), 'action_args' );
+ if ( $sanitized instanceof WP_Error ) {
+ return $this->get_invalid_action_args_error( $sanitized );
+ }
+
+ return is_array( $sanitized ) ? Coercion::coerce_string_keyed( $sanitized ) : array();
+ }
+
+ /**
+ * The 400 for `action_args` that do not match the schema, carrying the schema error's message.
+ *
+ * @param WP_Error $error Schema validation or sanitization error.
+ */
+ private function get_invalid_action_args_error( WP_Error $error ): WP_Error {
+ return new WP_Error(
+ 'woocommerce_subscriptions_engine_invalid_action_args',
+ $error->get_error_message(),
+ array(
+ 'status' => 400,
+ 'reason' => $error->get_error_code(),
+ )
+ );
+ }
+
+ /**
+ * The resolved args schemas as discovery shows them: schema keywords and `required` only,
+ * the way the WordPress REST index describes route args. An object, so no args encodes as `{}`.
+ *
+ * @param array<string, array<string, mixed>> $properties Property schemas.
+ */
+ private function get_args_for_response( array $properties ): object {
+ $keywords = array_flip( rest_get_allowed_schema_keywords() );
+ $args = array();
+ foreach ( $properties as $name => $property ) {
+ $args[ $name ] = array_intersect_key( $property, $keywords );
+ $args[ $name ]['required'] = ! empty( $property['required'] );
+ }
+
+ return (object) $args;
+ }
+
+ /**
+ * Log an extension callback failure and return a generic 500.
+ *
+ * @param Throwable $e Failure.
+ * @param WP_REST_Request $request Request.
+ */
+ private function get_action_failed_error( Throwable $e, WP_REST_Request $request ): WP_Error {
+ $contract_id = Coercion::coerce_int( $request->get_param( 'id' ) );
+ wc_get_logger()->error(
+ sprintf( 'ContractsController: contract action "%s" on contract %d failed: %s', Coercion::coerce_string( $request->get_param( 'action' ) ), $contract_id, $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => $contract_id,
+ )
+ );
+
+ return new WP_Error(
+ 'woocommerce_subscriptions_engine_action_failed',
+ __( 'The action could not be completed.', 'woocommerce-subscriptions-engine' ),
+ array( 'status' => 500 )
);
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index df5232ab987..8f0455ae92c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -1,9 +1,9 @@
<?php
/**
- * Subscriptions - the engine's interim lifecycle and renewal facade.
+ * Subscriptions - the engine's interim renewal facade.
*
- * Cancel, hold, reactivate, renew now, and read a contract's related orders. It hides
- * the internal `Core\` / `Integration\` collaborators behind a stable boundary.
+ * Renew now and read a contract's related orders. It hides the internal `Core\` /
+ * `Integration\` collaborators behind a stable boundary.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api
*/
@@ -14,19 +14,15 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
use WC_Order;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\RelatedOrders;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
defined( 'ABSPATH' ) || exit;
/**
* Public subscriptions facade.
*
- * Interim: removed once lifecycle flows and renewals move to extensions. Add no new
- * methods here; contract reads and writes live in {@see Contracts}.
+ * Interim: removed once renewals move to extensions. Add no new methods here;
+ * contract reads and writes live in {@see Contracts}.
*
* Final and static-only: a stateless entry point, not an extension seam.
*/
@@ -50,69 +46,6 @@ final class Subscriptions {
return ( new RelatedOrders() )->for_contract( $contract_id, $limit, $offset );
}
- /**
- * Cancel a subscription contract.
- *
- * @param int $contract_id Contract id.
- * @return bool True when the contract was found and cancelled; false when not found.
- */
- public static function cancel( int $contract_id ): bool {
- $contract = ( new ContractRepository() )->find( $contract_id );
- if ( null === $contract ) {
- return false;
- }
-
- return ( new Cancellation() )->cancel( $contract );
- }
-
- /**
- * Put a subscription contract on hold (suspend billing).
- *
- * @param int $contract_id Contract id.
- * @return bool True when the contract was found and held; false when not found.
- * @throws \DomainException If the contract cannot be held from its current state.
- */
- public static function hold( int $contract_id ): bool {
- $contract = ( new ContractRepository() )->find( $contract_id );
- if ( null === $contract ) {
- return false;
- }
-
- return ( new Hold() )->hold( $contract );
- }
-
- /**
- * Reactivate a held subscription contract (resume billing, recompute the next date).
- *
- * @param int $contract_id Contract id.
- * @return bool True when the contract was found and reactivated; false when not found.
- * @throws \DomainException If the contract cannot be reactivated from its current state.
- */
- public static function reactivate( int $contract_id ): bool {
- $contract = ( new ContractRepository() )->find( $contract_id );
- if ( null === $contract ) {
- return false;
- }
-
- return ( new Reactivation() )->reactivate( $contract );
- }
-
- /**
- * Cancel a subscription contract at the end of the current billing period.
- *
- * @param int $contract_id Contract id.
- * @return bool True when the contract was found and wound down; false when not found.
- * @throws \DomainException If the contract cannot be wound down from its current state.
- */
- public static function cancel_at_period_end( int $contract_id ): bool {
- $contract = ( new ContractRepository() )->find( $contract_id );
- if ( null === $contract ) {
- return false;
- }
-
- return ( new Cancellation() )->cancel_at_period_end( $contract );
- }
-
/**
* Renew the contract now on an admin's request, regardless of the schedule. A settled cycle
* is billed ahead of its due date (the period continues from the previous end, so the schedule
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 7fa2ee8e14b..d9a4eedbb92 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -4,9 +4,9 @@
*
* 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
+ * does read one payload with it: renewal parses 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
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 503d1bd20e7..6db119659ce 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -16,6 +16,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
@@ -47,6 +48,7 @@ final class Bootstrap {
self::$initialized = true;
CapabilityRegistry::init();
+ ContractCapabilities::register_hooks();
// Register the callbacks that dispatch renewals back into the engine. Plain
// add_action calls, safe before Action Scheduler has loaded; must run on every
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
deleted file mode 100644
index ad7b0a3723e..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ /dev/null
@@ -1,199 +0,0 @@
-<?php
-/**
- * Cancellation - cancel a subscription contract, immediately or at period end.
- *
- * A focused contract-management operation (deliberately not a catch-all manager)
- * covering the one cancel intent in its two modes: {@see self::cancel()} tears the
- * contract down NOW (transition to cancelled, close any charge caught mid-flight,
- * announce it), while {@see self::cancel_at_period_end()} winds it down gracefully
- * (transition to pending-cancellation, stamp the end date, keep serving until the
- * period lapses). Both modes disarm the contract's next-due moment themselves: the batch
- * due scan keys on `next_payment_gmt` and a registered owner, so the flow stops renewals by
- * clearing its own due moment rather than relying on status. Their preconditions are
- * the flow's own, not rules of the status primitive. Lives under `Integration\Contracts`
- * so contract lifecycle stays separate from the renewal money-path.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
-
-use RuntimeException;
-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\Integration\Storage\ContractRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Cancel a contract.
- */
-final class Cancellation {
-
- /**
- * Action fired after a contract is cancelled, with `( $contract )`.
- */
- public const CONTRACT_CANCELLED_ACTION = 'woocommerce_subscriptions_engine_contract_cancelled';
-
- /**
- * Action fired after a contract is set to wind down at period end, with `( $contract )`.
- */
- public const CONTRACT_PENDING_CANCELLATION_ACTION = 'woocommerce_subscriptions_engine_contract_pending_cancellation';
-
- /**
- * Contract repository.
- *
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * Construct.
- *
- * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
- */
- public function __construct( ?ContractRepository $contracts = null ) {
- $this->contracts = $contracts ?? new ContractRepository();
- }
-
- /**
- * Cancel `$contract`: move it to cancelled, disarm its next-due moment, and close any
- * mid-charge cycle.
- *
- * Only a draft, active, on-hold or pending-cancellation contract can be cancelled (a draft is
- * never armed, so there is no due moment to disarm); cancelling an
- * already cancelled contract is an idempotent no-op that still succeeds and fires the
- * action. Any other status - including one that is not registered - raises a
- * `DomainException`. The next-payment date and any hold anchor are cleared so the due scan
- * never selects the contract again. When the chain's most-recent cycle is still `pending`
- * (a charge caught mid-flight) it is transitioned `cancelled` so a stale claim is not left
- * open; a settled cycle is untouched.
- *
- * @param Contract $contract Contract to cancel. Must have an id.
- * @return bool True when the contract was cancelled and persisted.
- * @throws RuntimeException If the contract has no id.
- * @throws \DomainException If the contract cannot be cancelled from its current state, or its state changed concurrently.
- */
- public function cancel( Contract $contract ): bool {
- $id = $contract->get_id();
- if ( null === $id ) {
- throw new RuntimeException( 'Cancellation::cancel(): cannot cancel a contract that has no id.' );
- }
-
- $previous = $contract->get_status();
- $cancelable = array( ContractStatus::DRAFT, ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED );
- if ( ! in_array( $previous, $cancelable, true ) ) {
- throw new \DomainException( 'Cancellation::cancel(): only a draft, active, on-hold or pending-cancellation contract can be cancelled.' );
- }
-
- if ( ContractStatus::CANCELLED !== $previous ) {
- $contract->set_status( ContractStatus::CANCELLED );
- $contract->set_next_payment_gmt( null );
- }
-
- // Compare-and-set on the status read above: a concurrent transition (another
- // request, the renewal engine's settle) makes this write miss loudly rather
- // than be clobbered.
- if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
- throw new \DomainException( 'Cancellation::cancel(): the contract state changed concurrently; nothing was written.' );
- }
-
- if ( ContractStatus::CANCELLED !== $previous ) {
- Hold::clear_anchor( $this->contracts, $id );
- }
-
- // Close a charge caught mid-flight: a still-pending head cycle is cancelled so no stale
- // claim is left open. A settled (billed/failed/cancelled) cycle is left as is.
- $current = $this->contracts->find_chain_head( $id );
- if ( null !== $current && $current->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) ) {
- $current->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
- $this->contracts->update_cycle( $current );
- }
-
- /**
- * Fires after a contract is cancelled. Fires immediately after the write, not after a surrounding transaction commits.
- *
- * @param Contract $contract The cancelled contract.
- */
- do_action( self::CONTRACT_CANCELLED_ACTION, $contract );
-
- return true;
- }
-
- /**
- * Wind `$contract` down at the end of the current period: move it to
- * pending-cancellation, stamp the end date, and disarm its next-due moment.
- *
- * Only an active or on-hold contract can be wound down; winding down an already
- * pending-cancellation contract is an idempotent no-op that still succeeds and fires the
- * action. Any other status - including one that is not registered - raises a
- * `DomainException`. The contract keeps serving until the current period ends, so the
- * next-due moment (the next-payment date, or for a held contract the hold anchor) is
- * recorded as the contract `end_gmt` when not already set, for a first-class "cancels on"
- * date. The next-payment date and any hold anchor are then cleared, so no renewal fires
- * while the contract winds down.
- *
- * TODO: terminating a PENDING_CANCELLATION contract when its `end_gmt` arrives - moving
- * it to CANCELLED/EXPIRED at period end - is a follow-up slice. The contract now has no
- * next-due moment, so it stays PENDING_CANCELLATION (and is never charged) until a later
- * terminate-at-date pass ends it at its `end_gmt`.
- *
- * @param Contract $contract Contract to wind down. Must have an id, and be ACTIVE or ON_HOLD.
- * @return bool True when the contract was wound down and persisted.
- * @throws RuntimeException If the contract has no id.
- * @throws \DomainException If the contract cannot be wound down from its current state, or its state changed concurrently.
- */
- public function cancel_at_period_end( Contract $contract ): bool {
- $id = $contract->get_id();
- if ( null === $id ) {
- throw new RuntimeException( 'Cancellation::cancel_at_period_end(): cannot cancel a contract that has no id.' );
- }
-
- $previous = $contract->get_status();
- if ( ! in_array( $previous, array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION ), true ) ) {
- throw new \DomainException( 'Cancellation::cancel_at_period_end(): only an active or on-hold contract can be cancelled at period end.' );
- }
-
- if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
- $contract->set_status( ContractStatus::PENDING_CANCELLATION );
-
- // The end of the current period is the next-due moment: the contract is honoured
- // up to (not through) it. A held contract's moment lives in the hold anchor.
- $period_end = $contract->get_next_payment_gmt();
- if ( null === $period_end && ContractStatus::ON_HOLD === $previous ) {
- $period_end = Hold::read_anchor( $this->contracts, $id );
- }
- if ( null === $contract->get_end_gmt() && null !== $period_end ) {
- $contract->set_end_gmt( $period_end );
- }
-
- $contract->set_next_payment_gmt( null );
- }
-
- // Compare-and-set on the status read above: a concurrent transition makes this
- // write miss loudly rather than be clobbered.
- if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
- throw new \DomainException( 'Cancellation::cancel_at_period_end(): the contract state changed concurrently; nothing was written.' );
- }
-
- if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
- Hold::clear_anchor( $this->contracts, $id );
- }
-
- /**
- * Fires after a contract is set to wind down at the end of the current period.
- * Fires immediately after the write, not after a surrounding transaction commits.
- *
- * @param Contract $contract The pending-cancellation contract.
- */
- do_action( self::CONTRACT_PENDING_CANCELLATION_ACTION, $contract );
-
- return true;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
deleted file mode 100644
index 786b61dbd98..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
+++ /dev/null
@@ -1,208 +0,0 @@
-<?php
-/**
- * Hold - put an active subscription contract on hold (suspend billing).
- *
- * A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: move the contract ACTIVE -> ON_HOLD, disarm its
- * next-due moment, and announce it. The batch due scan keys on `next_payment_gmt` and a
- * registered owner (its active-status predicate is a renewal-flow condition, see
- * {@see ContractRepository::find_due()}), so the flow disarms its own due moment rather
- * than relying on status to stop billing. The cleared moment is kept in contract meta
- * ({@see self::ANCHOR_META_KEY}) so {@see Reactivation} can recompute the schedule
- * forward from it. Its preconditions are its own, not a rule of the status primitive.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use RuntimeException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Put a contract on hold.
- */
-final class Hold {
-
- /**
- * Action fired after a contract is put on hold, with `( $contract )`.
- */
- public const CONTRACT_HELD_ACTION = 'woocommerce_subscriptions_engine_contract_held';
-
- /**
- * Contract meta key holding the next-due moment cleared by a hold - the moment
- * {@see Reactivation} recomputes forward from.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- */
- public const ANCHOR_META_KEY = '_hold_next_payment_gmt';
-
- /**
- * Contract repository.
- *
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * Construct.
- *
- * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
- */
- public function __construct( ?ContractRepository $contracts = null ) {
- $this->contracts = $contracts ?? new ContractRepository();
- }
-
- /**
- * Hold `$contract`: move it to on-hold and disarm its next-due moment.
- *
- * Only an active contract can be held; holding an already on-hold contract is an
- * idempotent no-op that still succeeds and fires the action (nothing is rewritten,
- * so the stored anchor survives). Any other status - including one that is not
- * registered - raises a `DomainException`. The current cycle is immutable and is
- * NOT touched.
- *
- * @param Contract $contract Contract to hold. Must have an id, and be ACTIVE (or already ON_HOLD).
- * @return bool True when the contract was held and persisted.
- * @throws RuntimeException If the contract has no id.
- * @throws \DomainException If the contract cannot be held from its current state, or its state changed concurrently.
- */
- public function hold( Contract $contract ): bool {
- $id = $contract->get_id();
- if ( null === $id ) {
- throw new RuntimeException( 'Hold::hold(): cannot hold a contract that has no id.' );
- }
-
- $previous = $contract->get_status();
- if ( ContractStatus::ACTIVE !== $previous && ContractStatus::ON_HOLD !== $previous ) {
- throw new \DomainException( 'Hold::hold(): only an active contract can be held.' );
- }
-
- if ( ContractStatus::ACTIVE === $previous ) {
- $this->persist_anchor( $contract );
-
- $contract->set_status( ContractStatus::ON_HOLD );
- $contract->set_next_payment_gmt( null );
- }
-
- // Compare-and-set on the status read above: a concurrent transition (another
- // request, the renewal engine) makes this write miss loudly rather than be
- // clobbered. The anchor is already stored, so a reader that sees the contract
- // on hold always finds it.
- if ( ! $this->contracts->update_if_status( $contract, $previous ) ) {
- throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
- }
-
- /**
- * Fires after a contract is put on hold. Fires immediately after the write, not after a surrounding transaction commits.
- *
- * @param Contract $contract The held contract.
- */
- do_action( self::CONTRACT_HELD_ACTION, $contract );
-
- return true;
- }
-
- /**
- * Store the next-due moment as the hold anchor before the hold disarms it.
- *
- * The anchor lives in contract meta, written apart from the row (no transaction),
- * so it is written and read back first: if it did not persist, nothing has been
- * disarmed yet and the hold aborts. A contract with no next-due moment stores no
- * anchor. An anchor left behind by a hold that then loses its compare-and-set is
- * harmless: the next hold overwrites it and cancellation clears it.
- *
- * @param Contract $contract Active contract about to be held. Must have an id.
- * @throws RuntimeException If the anchor could not be stored.
- */
- private function persist_anchor( Contract $contract ): void {
- $id = (int) $contract->get_id();
- $next_payment_gmt = $contract->get_next_payment_gmt();
-
- try {
- if ( null === $next_payment_gmt ) {
- $this->contracts->delete_meta( $id, self::ANCHOR_META_KEY );
- } else {
- $this->contracts->update_meta( $id, self::ANCHOR_META_KEY, $next_payment_gmt );
- }
- } catch ( RuntimeException $e ) {
- throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the previous exception is not output.
- }
-
- $stored = $this->contracts->get_meta( $id, self::ANCHOR_META_KEY, true );
- $anchor = '' === $stored ? null : $stored;
- if ( $anchor !== $next_payment_gmt ) {
- throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.' );
- }
- }
-
- /**
- * Clear a contract's hold anchor after a status write has committed.
- *
- * Best effort: a failed delete is logged and swallowed, so the caller still finishes
- * the transition it already wrote (cycle close, lifecycle action). A leftover anchor
- * is harmless: the next hold overwrites it and only an on-hold contract reads it.
- *
- * @param ContractRepository $contracts Contract repository.
- * @param int $contract_id Contract id.
- */
- public static function clear_anchor( ContractRepository $contracts, int $contract_id ): void {
- try {
- $contracts->delete_meta( $contract_id, self::ANCHOR_META_KEY );
- } catch ( RuntimeException $e ) {
- wc_get_logger()->warning(
- sprintf( 'Hold: the hold anchor of contract %d could not be cleared: %s', $contract_id, $e->getMessage() ),
- array(
- 'source' => 'woocommerce-subscriptions-engine',
- 'contract_id' => $contract_id,
- )
- );
- }
- }
-
- /**
- * The hold anchor stored for a contract, or null when there is none.
- *
- * The one reader of {@see self::ANCHOR_META_KEY}: a value that is not a well-formed
- * GMT datetime (`Y-m-d H:i:s`) counts as absent and is logged, since a flow resuming
- * or ending from it would otherwise act on garbage.
- *
- * @param ContractRepository $contracts Contract repository.
- * @param int $contract_id Contract id.
- */
- public static function read_anchor( ContractRepository $contracts, int $contract_id ): ?string {
- $anchor = $contracts->get_meta( $contract_id, self::ANCHOR_META_KEY, true );
- if ( '' === $anchor || null === $anchor ) {
- return null;
- }
-
- if ( is_string( $anchor ) ) {
- $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
- if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
- return $anchor;
- }
- }
-
- wc_get_logger()->warning(
- sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', $contract_id ),
- array(
- 'source' => 'woocommerce-subscriptions-engine',
- 'contract_id' => $contract_id,
- )
- );
-
- return null;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
deleted file mode 100644
index 3200a85e860..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ /dev/null
@@ -1,292 +0,0 @@
-<?php
-/**
- * Reactivation - resume a held subscription contract (resume billing).
- *
- * A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: move the contract ON_HOLD -> ACTIVE, re-arm its
- * next-due moment forward from the hold anchor, and announce it. Writing the forward
- * next-payment date is the re-arm (the batch due scan, keyed on `next_payment_gmt` and a
- * registered owner, picks the contract up at that date), not the status change alone.
- * Lives under `Integration\Contracts` so contract lifecycle stays separate from the
- * renewal money-path. Its preconditions are its own, not a rule of the status primitive.
- *
- * Interim: moves out of the engine with the lifecycle flows (hold / reactivate /
- * cancel and their routes).
- *
- * `$now` is read at this integration boundary (or injected for tests) and the cadence
- * math is delegated to the clock-free {@see RenewalCalculator}, so the engine keeps a
- * single cadence path and Core takes no clock.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use DomainException;
-use RuntimeException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Reactivate a held contract.
- */
-final class Reactivation {
-
- /**
- * Action fired after a contract is reactivated, with `( $contract )`.
- */
- public const CONTRACT_REACTIVATED_ACTION = 'woocommerce_subscriptions_engine_contract_reactivated';
-
- /**
- * Bound on the forward roll so a pathological policy (or a very long-held contract)
- * cannot loop unboundedly while moving a past-due date into the future.
- */
- private const MAX_FORWARD_ROLLS = 1000;
-
- /**
- * Log source, matching the package's shared logging channel.
- */
- private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
-
- /**
- * Contract repository.
- *
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * Plan repository, for the billing policy the forward recompute rolls on.
- *
- * @var PlanRepository
- */
- private $plans;
-
- /**
- * Construct.
- *
- * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
- * @param PlanRepository|null $plans Plan repository; default instance when omitted.
- */
- public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null ) {
- $this->contracts = $contracts ?? new ContractRepository();
- $this->plans = $plans ?? new PlanRepository();
- }
-
- /**
- * Reactivate `$contract`: move it to active, re-arm the next-payment date forward,
- * and persist.
- *
- * The anchor the date is recomputed from is the stored `next_payment_gmt` when one is
- * set (hold clears it, so a value means it was re-armed deliberately, or the contract
- * was held before hold disarmed it), else the next-due moment stashed by {@see Hold}
- * ({@see Hold::ANCHOR_META_KEY}), read through {@see Hold::read_anchor()}, which logs
- * and ignores a malformed value. The
- * anchor meta is removed. The date is recomputed through the single seam
- * ({@see self::recompute_next_payment()}) so a contract that sat on hold past its due
- * date does not fire an immediate, back-dated renewal the moment it resumes; with no
- * anchor the contract simply stays unscheduled.
- *
- * @param Contract $contract Contract to reactivate. Must have an id, and be ON_HOLD.
- * @param DateTimeImmutable|null $now The current moment; read from the wall clock (UTC) when omitted.
- * @return bool True when the contract was reactivated and persisted.
- * @throws RuntimeException If the contract has no id.
- * @throws DomainException If the contract is not on hold, or its state changed concurrently.
- */
- public function reactivate( Contract $contract, ?DateTimeImmutable $now = null ): bool {
- $id = $contract->get_id();
- if ( null === $id ) {
- throw new RuntimeException( 'Reactivation::reactivate(): cannot reactivate a contract that has no id.' );
- }
-
- // Only a held contract reactivates. In particular an already-ACTIVE contract must
- // not reach the recompute below: rolling a past-due active contract's next-payment
- // date forward would skip the charge the due scan owes it.
- if ( ContractStatus::ON_HOLD !== $contract->get_status() ) {
- throw new DomainException( 'Reactivation::reactivate(): only an on-hold contract can be reactivated.' );
- }
-
- // Read the clock at the integration boundary so the Core cadence math stays clock-free.
- $now = ( $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->setTimezone( new DateTimeZone( 'UTC' ) );
-
- // A next-due moment set while held was re-armed deliberately (hold clears it), so
- // it wins; otherwise resume from the hold anchor, ignoring a malformed one.
- $anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $this->contracts, $id );
-
- $contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $anchor, $now, $this->billing_policy( $contract ) ) );
- $contract->set_status( ContractStatus::ACTIVE );
-
- // Compare-and-set on the ON_HOLD status read above: a concurrent transition
- // (another request, the renewal engine) makes this write miss loudly rather
- // than be clobbered.
- if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ON_HOLD ) ) {
- throw new DomainException( 'Reactivation::reactivate(): the contract state changed concurrently; nothing was written.' );
- }
-
- Hold::clear_anchor( $this->contracts, $id );
-
- /**
- * Fires after a held contract is reactivated: its renewal is re-armed, or left
- * unscheduled when there was no next-due moment to resume from. Fires immediately after the write, not after a surrounding transaction commits.
- *
- * @param Contract $contract The reactivated contract.
- */
- do_action( self::CONTRACT_REACTIVATED_ACTION, $contract );
-
- return true;
- }
-
- /**
- * Recompute the next-payment date when a held contract reactivates.
- *
- * THE SINGLE SWAPPABLE RECOMPUTE SEAM. The exact behaviour here is a pending PRODUCT
- * DECISION; every change to it is isolated to this one method so the rest of the
- * lifecycle wiring stays stable when the policy is finalized.
- *
- * Default = "Model 1" (suspend without mutating the immutable current cycle;
- * reactivate recomputes the next date FORWARD, with no catch-up / back-charge):
- *
- * - A future anchor date is kept as-is - resuming before the date arrives changes
- * nothing.
- * - A past-due date (the contract sat on hold past it) is rolled forward by whole
- * billing cadences (via {@see RenewalCalculator::next_bill_date()}) until it is in
- * the future, so resuming does not immediately fire a back-dated renewal. With no
- * policy available to compute a cadence, the date is floored at `$now` (the due
- * scan then bills the resumed contract on its next pass rather than for the held
- * window).
- * - A contract with no anchor (no scheduled next payment when held) stays unscheduled.
- *
- * Models 2 (resume immediately and charge for the held period) and 3 (extend the end
- * date by the held duration) are deliberately NOT implemented - do not add them here
- * until the product decision lands.
- *
- * @param Contract $contract The contract being reactivated (for the log line).
- * @param string|null $anchor The GMT next-due moment to recompute from, or null.
- * @param DateTimeImmutable $now The current moment (UTC; injected at the boundary).
- * @param BillingPolicy|null $policy The plan billing policy for the forward roll, or null.
- * @return string|null The recomputed next-payment GMT string, or null when unscheduled.
- */
- private function recompute_next_payment( Contract $contract, ?string $anchor, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
- if ( null === $anchor ) {
- return null;
- }
-
- $next = new DateTimeImmutable( $anchor, new DateTimeZone( 'UTC' ) );
-
- // Still in the future: resuming before the date arrives keeps the schedule.
- if ( $next > $now ) {
- return $next->format( 'Y-m-d H:i:s' );
- }
-
- // Past due while held. With no cadence to roll by, floor at `$now` so the due scan
- // bills the resumed contract on its next pass, not for the held gap.
- if ( null === $policy ) {
- return $now->format( 'Y-m-d H:i:s' );
- }
-
- // Roll forward by whole cadences until the date is in the future.
- $rolls = self::MAX_FORWARD_ROLLS;
- while ( $next <= $now && $rolls-- > 0 ) {
- $next = RenewalCalculator::next_bill_date( $policy, $next );
- }
-
- if ( $next <= $now ) {
- // The cap ran out before the date cleared `$now` (a very long hold on a
- // fine-grained cadence): floor at `$now` like the no-policy branch above, so
- // the resume never lands a back-dated renewal. Logged because a capped roll
- // means the schedule anchor left the plan's cadence grid.
- wc_get_logger()->warning(
- sprintf( 'Reactivation: contract %d exhausted the forward-roll cap; next payment floored at now.', (int) $contract->get_id() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
-
- return $now->format( 'Y-m-d H:i:s' );
- }
-
- return $next->format( 'Y-m-d H:i:s' );
- }
-
- /**
- * 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
- * (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 ) {
- 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(),
- )
- );
- }
- }
-
- $plan_id = $contract->get_selling_plan_id();
- if ( null === $plan_id ) {
- return null;
- }
-
- $plan = $this->plans->find( $plan_id );
- if ( null === $plan ) {
- return 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/Ownership/ContractCapabilities.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ContractCapabilities.php
new file mode 100644
index 00000000000..ddfe813d0ae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ContractCapabilities.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * ContractCapabilities - the `read_subscription_contract` and `manage_subscription_contract` meta capabilities.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Maps the contract capabilities: the contract's customer needs `read`, anyone else
+ * `manage_woocommerce`. Checked as `current_user_can( $capability, $contract )` with a
+ * `ContractView`; anything else is refused. The mapping runs first (priority 0), so extensions
+ * adjust each capability with `map_meta_cap` at the default priority, or with `user_has_cap`.
+ */
+final class ContractCapabilities {
+
+ /**
+ * Read one contract and the actions available for it.
+ */
+ public const READ = 'read_subscription_contract';
+
+ /**
+ * Manage one contract: run actions on it.
+ */
+ public const MANAGE = 'manage_subscription_contract';
+
+ /**
+ * Hook the mapping ahead of other `map_meta_cap` filters, so theirs build on it.
+ */
+ public static function register_hooks(): void {
+ add_filter( 'map_meta_cap', array( self::class, 'map_meta_cap' ), 0, 4 );
+ }
+
+ /**
+ * Map the meta capability to the primitive capabilities the user needs.
+ *
+ * @param mixed $caps Primitive capabilities so far.
+ * @param mixed $cap Capability being checked.
+ * @param mixed $user_id User id.
+ * @param mixed $args Extra `current_user_can()` arguments; the first is the contract.
+ * @return mixed
+ */
+ public static function map_meta_cap( $caps, $cap, $user_id, $args ) {
+ if ( self::READ !== $cap && self::MANAGE !== $cap ) {
+ return $caps;
+ }
+
+ $contract = is_array( $args ) ? ( $args[0] ?? null ) : null;
+ if ( ! $contract instanceof ContractView ) {
+ return array( 'do_not_allow' );
+ }
+
+ $customer_id = $contract->get_customer_id();
+ if ( null !== $customer_id && $customer_id > 0 && Coercion::coerce_int( $user_id ) === $customer_id ) {
+ return array( 'read' );
+ }
+
+ return array( 'manage_woocommerce' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Rest/ContractActionRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Rest/ContractActionRegistry.php
new file mode 100644
index 00000000000..d1ef6131512
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Rest/ContractActionRegistry.php
@@ -0,0 +1,156 @@
+<?php
+/**
+ * ContractActionRegistry - the contract actions extensions register for the action endpoint.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest;
+
+use UnexpectedValueException;
+use WP_REST_Request;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registered contract actions, keyed by extension slug then action, and their per-request
+ * resolution (permission, availability, args schema) against a contract.
+ *
+ * @internal Written through {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions::register()} only, which validates every definition.
+ *
+ * @phpstan-type ContractActionDefinition array{extension_slug: string, action: string, callback: callable, permission: string|callable, description: string, args: array<string, array<string, mixed>>|callable, is_available: callable|null}
+ */
+final class ContractActionRegistry {
+
+ /**
+ * Registered actions keyed by extension slug, then action.
+ *
+ * @var array<string, array<string, ContractActionDefinition>>
+ */
+ private static $actions = array();
+
+ /**
+ * Store a validated action definition.
+ *
+ * @param array $definition Action definition.
+ * @phpstan-param ContractActionDefinition $definition
+ */
+ public static function add( array $definition ): void {
+ self::$actions[ $definition['extension_slug'] ][ $definition['action'] ] = $definition;
+ }
+
+ /**
+ * Whether the extension registered the action.
+ *
+ * @param string $extension_slug Extension slug.
+ * @param string $action Action slug.
+ */
+ public static function has( string $extension_slug, string $action ): bool {
+ return isset( self::$actions[ $extension_slug ][ $action ] );
+ }
+
+ /**
+ * The extension's action definition, or null when not registered.
+ *
+ * @param string $extension_slug Extension slug.
+ * @param string $action Action slug.
+ * @return ContractActionDefinition|null
+ */
+ public static function get( string $extension_slug, string $action ): ?array {
+ return self::$actions[ $extension_slug ][ $action ] ?? null;
+ }
+
+ /**
+ * The extension's action definitions, in registration order.
+ *
+ * @param string $extension_slug Extension slug.
+ * @return array<int, ContractActionDefinition>
+ */
+ public static function get_for_extension( string $extension_slug ): array {
+ return array_values( self::$actions[ $extension_slug ] ?? array() );
+ }
+
+ /**
+ * Whether the current user may run the action on the contract.
+ *
+ * @param array $definition Action definition.
+ * @param ContractView $contract Contract.
+ * @param WP_REST_Request $request Request.
+ * @phpstan-param ContractActionDefinition $definition
+ */
+ public static function is_permitted( array $definition, ContractView $contract, WP_REST_Request $request ): bool {
+ $permission = $definition['permission'];
+ if ( is_string( $permission ) ) {
+ // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- the extension names the capability.
+ return current_user_can( $permission, $contract );
+ }
+
+ return true === $permission( $contract, $request );
+ }
+
+ /**
+ * Whether the action is available for the contract now; actions without `is_available` always are.
+ *
+ * @param array $definition Action definition.
+ * @param ContractView $contract Contract.
+ * @phpstan-param ContractActionDefinition $definition
+ */
+ public static function is_available( array $definition, ContractView $contract ): bool {
+ return null === $definition['is_available'] || true === ( $definition['is_available'] )( $contract );
+ }
+
+ /**
+ * The `action_args` property schemas for the contract, resolving a callable `args`.
+ *
+ * @param array $definition Action definition.
+ * @param ContractView $contract Contract.
+ * @phpstan-param ContractActionDefinition $definition
+ * @return array<string, array<string, mixed>>
+ * @throws UnexpectedValueException If a callable `args` returns something other than an array of schemas.
+ */
+ public static function get_args_schema( array $definition, ContractView $contract ): array {
+ $args = $definition['args'];
+ if ( ! is_callable( $args ) ) {
+ return $args;
+ }
+
+ $resolved_args = $args( $contract );
+ if ( ! self::is_args_schema( $resolved_args ) ) {
+ throw new UnexpectedValueException( sprintf( 'The "args" callback of contract action "%s" must return an array of property schemas.', esc_html( $definition['action'] ) ) );
+ }
+
+ return $resolved_args;
+ }
+
+ /**
+ * Whether the value is an `args` schema map: string property names to schema arrays.
+ *
+ * @param mixed $value Value.
+ * @phpstan-assert-if-true array<string, array<string, mixed>> $value
+ */
+ public static function is_args_schema( $value ): bool {
+ if ( ! is_array( $value ) ) {
+ return false;
+ }
+
+ foreach ( $value as $name => $schema ) {
+ if ( ! is_string( $name ) || ! is_array( $schema ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Clear every registration.
+ *
+ * @internal Public only so tests can isolate per-test state.
+ */
+ public static function reset(): void {
+ self::$actions = array();
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index a4358b88b20..91b34e0d915 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -193,10 +193,10 @@ final class ContractRepository {
* `$expected_status` - the optimistic compare-and-set for status-sensitive writes,
* mirroring {@see self::transition_cycle_status()} on the cycle side.
*
- * The lifecycle transitions (hold / reactivate / cancel) and the renewal engine's
- * schedule advances all read-validate-write the contract row; unconditioned, the
- * slower writer silently clobbers the faster one - a customer cancel lost to a
- * concurrent settle would resurrect the contract into future billing. Keying the
+ * The renewal engine's schedule advances read-validate-write the contract row while an
+ * extension may change its status (a customer cancel); unconditioned, the slower writer
+ * silently clobbers the faster one - a cancel lost to a concurrent settle would
+ * resurrect the contract into future billing. Keying the
* write on the status the caller read makes the race lose LOUDLY: no row matches,
* false comes back, and the caller reports a conflict or re-reads instead of
* overwriting.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
index 1e81dd6cadd..0c1b189e8c2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/RESTPermissions.php
@@ -19,10 +19,9 @@ class RESTPermissions {
/**
* Require a logged in user.
*
- * The shared authentication floor for customer-facing routes: any logged-in user
- * passes; resource-level authorization (e.g. per-contract ownership) stays with the
- * route handlers. Core's cookie auth has already verified the REST nonce (`wp_rest`)
- * for a cookie-authenticated request by the time a permission callback runs.
+ * The authentication floor under {@see self::require_admin_permission()}. Core's cookie
+ * auth has already verified the REST nonce (`wp_rest`) for a cookie-authenticated
+ * request by the time a permission callback runs.
*
* @return true|\WP_Error True when logged in, else a 401 error.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
new file mode 100644
index 00000000000..7087fede5c7
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractActionsTest.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ * Integration tests for the contract actions registration facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry
+ */
+class ContractActionsTest extends EngineIntegrationTestCase {
+
+ private const EXTENSION_SLUG = 'test-extension';
+
+ private const REGISTER = 'Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions::register';
+
+ public function set_up(): void {
+ parent::set_up();
+ ContractActionRegistry::reset();
+ }
+
+ public function tear_down(): void {
+ ContractActionRegistry::reset();
+ parent::tear_down();
+ }
+
+ /**
+ * A valid registration is stored with its defaults.
+ */
+ public function test_registers_an_action_with_defaults(): void {
+ $callback = array( $this, 'return_contract' );
+
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ 'pause',
+ array(
+ 'callback' => $callback,
+ 'permission' => 'manage_subscription_contract',
+ )
+ );
+
+ $definition = ContractActionRegistry::get( self::EXTENSION_SLUG, 'pause' );
+ $this->assertNotNull( $definition );
+ $this->assertSame( self::EXTENSION_SLUG, $definition['extension_slug'] );
+ $this->assertSame( 'pause', $definition['action'] );
+ $this->assertSame( $callback, $definition['callback'] );
+ $this->assertSame( 'manage_subscription_contract', $definition['permission'] );
+ $this->assertSame( '', $definition['description'] );
+ $this->assertSame( array(), $definition['args'] );
+ $this->assertNull( $definition['is_available'] );
+ }
+
+ /**
+ * Actions are scoped to the registering extension.
+ */
+ public function test_actions_are_scoped_to_the_extension(): void {
+ ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() );
+ ContractActions::register( 'other-extension', 'pause', $this->valid_args() );
+ ContractActions::register( self::EXTENSION_SLUG, 'resume', $this->valid_args() );
+
+ $this->assertSame(
+ array( 'pause', 'resume' ),
+ array_column( ContractActionRegistry::get_for_extension( self::EXTENSION_SLUG ), 'action' )
+ );
+ $this->assertCount( 1, ContractActionRegistry::get_for_extension( 'other-extension' ) );
+ $this->assertSame( array(), ContractActionRegistry::get_for_extension( 'unknown' ) );
+ }
+
+ /**
+ * A duplicate registration raises a notice and keeps the first one.
+ */
+ public function test_duplicate_registration_keeps_the_first(): void {
+ $this->setExpectedIncorrectUsage( self::REGISTER );
+
+ ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() + array( 'description' => 'First' ) );
+ ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() + array( 'description' => 'Second' ) );
+
+ $definition = ContractActionRegistry::get( self::EXTENSION_SLUG, 'pause' );
+ $this->assertNotNull( $definition );
+ $this->assertSame( 'First', $definition['description'] );
+ }
+
+ /**
+ * Unknown keys raise a notice and are ignored; the action still registers.
+ */
+ public function test_unknown_keys_are_ignored(): void {
+ $this->setExpectedIncorrectUsage( self::REGISTER );
+
+ ContractActions::register( self::EXTENSION_SLUG, 'pause', $this->valid_args() + array( 'label' => 'Pause' ) );
+
+ $this->assertTrue( ContractActionRegistry::has( self::EXTENSION_SLUG, 'pause' ) );
+ }
+
+ /**
+ * Invalid registrations raise a notice and are not registered.
+ *
+ * @dataProvider invalid_registrations
+ *
+ * @param string $extension_slug Extension slug.
+ * @param string $action Action slug.
+ * @param array<string, mixed> $overrides Registration args to replace.
+ */
+ public function test_invalid_registration_is_rejected( string $extension_slug, string $action, array $overrides ): void {
+ $this->setExpectedIncorrectUsage( self::REGISTER );
+
+ // A null override removes the key.
+ $args = array_filter(
+ $overrides + $this->valid_args(),
+ static function ( $value ): bool {
+ return null !== $value;
+ }
+ );
+
+ ContractActions::register( $extension_slug, $action, $args );
+
+ $this->assertSame( array(), ContractActionRegistry::get_for_extension( $extension_slug ) );
+ }
+
+ /**
+ * Invalid registration cases.
+ *
+ * @return array<string, array{0: string, 1: string, 2: array<string, mixed>}>
+ */
+ public function invalid_registrations(): array {
+ return array(
+ 'empty extension slug' => array( ' ', 'pause', array() ),
+ 'action with capitals' => array( self::EXTENSION_SLUG, 'Pause', array() ),
+ 'action with a slash' => array( self::EXTENSION_SLUG, 'pause/now', array() ),
+ 'action with a newline' => array( self::EXTENSION_SLUG, "pause\n", array() ),
+ 'empty action' => array( self::EXTENSION_SLUG, '', array() ),
+ 'missing callback' => array( self::EXTENSION_SLUG, 'pause', array( 'callback' => null ) ),
+ 'non-callable callback' => array( self::EXTENSION_SLUG, 'pause', array( 'callback' => 'not_a_function_anywhere' ) ),
+ 'missing permission' => array( self::EXTENSION_SLUG, 'pause', array( 'permission' => null ) ),
+ 'empty permission' => array( self::EXTENSION_SLUG, 'pause', array( 'permission' => ' ' ) ),
+ 'non-callable permission' => array( self::EXTENSION_SLUG, 'pause', array( 'permission' => 42 ) ),
+ 'non-string description' => array( self::EXTENSION_SLUG, 'pause', array( 'description' => 5 ) ),
+ 'args not a schema map' => array( self::EXTENSION_SLUG, 'pause', array( 'args' => array( 'type' => 'boolean' ) ) ),
+ 'args a list' => array( self::EXTENSION_SLUG, 'pause', array( 'args' => array( array( 'type' => 'boolean' ) ) ) ),
+ 'non-callable is_available' => array( self::EXTENSION_SLUG, 'pause', array( 'is_available' => true ) ),
+ );
+ }
+
+ /**
+ * Callable `args` resolve per contract; a non-array result is an error.
+ */
+ public function test_callable_args_resolve_per_contract(): void {
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ 'pause',
+ $this->valid_args() + array(
+ 'args' => static function ( ContractView $contract ): array {
+ return array(
+ 'reason' => array(
+ 'type' => 'string',
+ 'description' => $contract->get_status(),
+ ),
+ );
+ },
+ )
+ );
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ 'broken',
+ $this->valid_args() + array(
+ 'args' => static function (): string {
+ return 'nope';
+ },
+ )
+ );
+ $contract = $this->create_contract();
+
+ $pause = ContractActionRegistry::get( self::EXTENSION_SLUG, 'pause' );
+ $this->assertNotNull( $pause );
+ $this->assertSame(
+ array(
+ 'reason' => array(
+ 'type' => 'string',
+ 'description' => 'active',
+ ),
+ ),
+ ContractActionRegistry::get_args_schema( $pause, $contract )
+ );
+
+ $broken = ContractActionRegistry::get( self::EXTENSION_SLUG, 'broken' );
+ $this->assertNotNull( $broken );
+ $this->expectException( \UnexpectedValueException::class );
+ ContractActionRegistry::get_args_schema( $broken, $contract );
+ }
+
+ /**
+ * Valid registration args.
+ *
+ * @return array<string, mixed>
+ */
+ private function valid_args(): array {
+ return array(
+ 'callback' => array( $this, 'return_contract' ),
+ 'permission' => 'manage_woocommerce',
+ );
+ }
+
+ /**
+ * An active contract owned by the test extension.
+ */
+ private function create_contract(): ContractView {
+ return Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => 'active',
+ )
+ );
+ }
+
+ /**
+ * Action callback that returns the contract unchanged.
+ *
+ * @param ContractView $contract Contract.
+ */
+ public function return_contract( ContractView $contract ): ContractView {
+ return $contract;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
index 89c629be8f6..42b1aec01cd 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
@@ -1,9 +1,6 @@
<?php
/**
- * Integration tests for the lifecycle-actions REST controller: the auth + ownership
- * matrix (anonymous 401, valid owner 200, foreign owner 404, unknown id 404), the
- * action round-trips with their domain-summary responses, and the
- * precondition 409.
+ * Integration tests for the contracts REST controller.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -13,12 +10,15 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api\Rest;
use EngineIntegrationTestCase;
+use RuntimeException;
+use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractActions;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController
@@ -27,235 +27,697 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
private const BASE = '/wc/v3/subscriptions-engine/contracts';
- /**
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * @var int
- */
- private $owner_id;
+ private const EXTENSION_SLUG = 'test-extension';
/**
- * @var int
+ * Action callback calls, as `array( action, contract id, action_args )`.
+ *
+ * @var array<int, array{0: string, 1: int, 2: array<string, mixed>}>
*/
- private $other_id;
+ private $calls = array();
public function set_up(): void {
parent::set_up();
- $this->contracts = new ContractRepository();
+ ContractActionRegistry::reset();
+ $this->calls = array();
- // Register the controller on `rest_api_init` (where core requires routes to be
- // registered) and re-fire the action so the routes exist on the live server for
- // this test. Mirrors how Bootstrap wires it in production.
- add_action(
- 'rest_api_init',
- static function (): void {
- ( new ContractsController() )->register_routes();
- }
- );
- do_action( 'rest_api_init' );
-
- $this->owner_id = $this->create_customer();
- $this->other_id = $this->create_customer();
+ // A fresh server fires `rest_api_init`, so the routes come from the engine's own wiring.
+ $GLOBALS['wp_rest_server'] = null;
+ rest_get_server();
}
public function tear_down(): void {
+ ContractActionRegistry::reset();
+ $GLOBALS['wp_rest_server'] = null;
wp_set_current_user( 0 );
parent::tear_down();
}
/**
- * Create a customer user and return its id.
+ * A store manager reads the stored contract facts, children included.
*/
- private function create_customer(): int {
- $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
- $this->assertIsInt( $user_id );
+ public function test_get_returns_the_contract_to_a_store_manager(): void {
+ $contract = Contracts::create(
+ array(
+ 'extension_slug' => 'test-extension',
+ 'status' => 'active',
+ 'customer_id' => 7,
+ 'currency' => 'EUR',
+ 'next_payment_gmt' => '2026-11-01 10:00:00',
+ 'billing_total' => '20.00',
+ 'items' => array(
+ array(
+ 'item_name' => 'Coffee',
+ 'product_id' => 9,
+ 'quantity' => '2',
+ 'total' => '20',
+ ),
+ ),
+ 'addresses' => array(
+ 'billing' => array(
+ 'first_name' => 'Ada',
+ 'country' => 'PT',
+ ),
+ ),
+ )
+ );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
- return $user_id;
+ $response = $this->get( $contract->get_id() );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $this->response_data( $response );
+ $this->assertSame( $contract->get_id(), $data['id'] );
+ $this->assertSame( 'test-extension', $data['extension_slug'] );
+ $this->assertSame( 'active', $data['status'] );
+ $this->assertSame( 7, $data['customer_id'] );
+ $this->assertSame( 'EUR', $data['currency'] );
+ $this->assertSame( '2026-11-01T10:00:00', $data['next_payment_gmt'] );
+ $this->assertNull( $data['end_gmt'] );
+ $stored = Contracts::get( $contract->get_id() );
+ $this->assertNotNull( $stored );
+ $this->assertSame( $stored->get_billing_total(), $data['billing_total'] );
+ $this->assertSame( $stored->get_items(), $data['items'] );
+ $this->assertSame( $stored->get_addresses(), $data['addresses'] );
+ $this->assertNotEmpty( $stored->get_items() );
+ $this->assertNotEmpty( $stored->get_addresses() );
}
/**
- * Seed a contract for a customer.
- *
- * @param int $customer_id Owning customer.
- * @param string $status Status.
+ * A contract without addresses encodes them as an empty JSON object.
*/
- private function seed( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
- $contract = Contract::create(
+ public function test_get_encodes_no_addresses_as_an_object(): void {
+ $contract = Contracts::create(
array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => $customer_id,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'payment_method_title' => 'Visa ending in 4242',
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2099-02-01 00:00:00',
- 'billing_total' => '19.99',
+ 'extension_slug' => 'test-extension',
+ 'status' => 'active',
+ 'customer_id' => 7,
+ 'currency' => 'EUR',
)
);
+ wp_set_current_user( $this->create_user( 'administrator' ) );
- return $this->contracts->insert( $contract );
+ $response = $this->get( $contract->get_id() );
+
+ $this->assertStringContainsString( '"addresses":{}', (string) wp_json_encode( $response->get_data() ) );
}
- public function test_anonymous_request_is_unauthorized(): void {
- wp_set_current_user( 0 );
- $id = $this->seed( $this->owner_id );
+ /**
+ * An unknown id is a 404.
+ */
+ public function test_get_unknown_contract_is_not_found(): void {
+ wp_set_current_user( $this->create_user( 'administrator' ) );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+ $response = $this->get( 999999 );
- $this->assertSame( 401, $response->get_status() );
- // The contract is untouched.
- $this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_contract_not_found', $this->response_data( $response )['code'] );
}
- public function test_unknown_contract_is_not_found_indistinguishably_from_foreign(): void {
- wp_set_current_user( $this->other_id );
+ /**
+ * The contract's customer reads it; guests get a 401 and other customers the same 404 as an
+ * unknown contract.
+ */
+ public function test_get_needs_read_subscription_contract(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+
+ $this->assertSame( 401, $this->get( $contract->get_id() )->get_status() );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/4242424/hold' ) );
+ wp_set_current_user( $customer_id );
+ $this->assertSame( 200, $this->get( $contract->get_id() )->get_status() );
+ wp_set_current_user( $this->create_user( 'customer' ) );
+ $response = $this->get( $contract->get_id() );
$this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_contract_not_found', $this->response_data( $response )['code'] );
}
- public function test_options_exposes_the_action_schema(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id );
+ /**
+ * A `map_meta_cap` filter on the read capability decides the read.
+ */
+ public function test_get_follows_read_subscription_contract_filters(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $deny = static function ( $caps, $cap ) {
+ return 'read_subscription_contract' === $cap ? array( 'do_not_allow' ) : $caps;
+ };
+ add_filter( 'map_meta_cap', $deny, 20, 2 );
+ wp_set_current_user( $customer_id );
+
+ try {
+ $this->assertSame( 404, $this->get( $contract->get_id() )->get_status() );
+ } finally {
+ remove_filter( 'map_meta_cap', $deny, 20 );
+ }
+ }
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'OPTIONS', self::BASE . '/' . $id . '/hold' ) );
+ /**
+ * The engine serves the read route and the action route, nothing else.
+ */
+ public function test_registers_the_read_and_action_routes(): void {
+ $routes = array_filter(
+ array_keys( rest_get_server()->get_routes() ),
+ static function ( string $route ): bool {
+ return 0 === strpos( $route, self::BASE );
+ }
+ );
- $this->assertSame( 200, $response->get_status() );
- $data = $this->data_array( $response );
- $this->assertIsArray( $data['schema'] );
- $this->assertSame( 'subscription_engine_contract_action', $data['schema']['title'] );
+ $this->assertSame( array( self::BASE . '/(?P<id>[\d]+)', self::BASE . '/(?P<id>[\d]+)/action' ), array_values( $routes ) );
}
- public function test_hold_action_on_a_foreign_contract_is_not_found(): void {
- wp_set_current_user( $this->other_id );
- $id = $this->seed( $this->owner_id );
+ /**
+ * Anonymous callers get a 401 on both action routes.
+ */
+ public function test_actions_require_a_logged_in_user(): void {
+ $this->register_action( 'pause', 'manage_subscription_contract' );
+ $contract = $this->create_contract( $this->create_user( 'customer' ) );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+ $this->assertSame( 401, $this->list_actions( $contract->get_id() )->get_status() );
+ $this->assertSame( 401, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+ $this->assertSame( array(), $this->calls );
+ }
- $this->assertSame( 404, $response->get_status() );
- // The contract is untouched.
- $this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+ /**
+ * A run without an action is a 404 even past the route schema, never the first permitted action.
+ */
+ public function test_run_without_an_action_resolves_nothing(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $this->register_action( 'pause', 'manage_subscription_contract' );
+ wp_set_current_user( $customer_id );
+ $request = new WP_REST_Request( 'POST', self::BASE . '/' . $contract->get_id() . '/action' );
+ $request->set_url_params( array( 'id' => (string) $contract->get_id() ) );
+ $request->set_body_params( array( 'extension_slug' => self::EXTENSION_SLUG ) );
+
+ $result = ( new ContractsController() )->run_action_permissions_check( $request );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $error_data = $result->get_error_data();
+ $this->assertIsArray( $error_data );
+ $this->assertSame( 404, $error_data['status'] ?? null );
+ $this->assertSame( array(), $this->calls );
}
- public function test_owner_hold_transitions_and_returns_the_domain_summary(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id );
+ /**
+ * `manage_subscription_contract` lets the contract's customer run the action: the callback gets the
+ * contract and the validated args, and the response is the resulting id and status.
+ */
+ public function test_customer_runs_an_action_on_their_contract(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $this->register_action( 'pause', 'manage_subscription_contract', array( 'args' => array( 'note' => array( 'type' => 'string' ) ) ) );
+ wp_set_current_user( $customer_id );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+ $response = $this->run_action( $contract->get_id(), 'pause', array( 'note' => 'Away' ) );
$this->assertSame( 200, $response->get_status() );
- $data = $this->data_array( $response );
- // The action response is a domain summary: id + resulting status slug,
- // no view-model fields (labels, formatted values, visibility flags).
- $this->assertSame( $id, $data['id'] );
- $this->assertSame( ContractStatus::ON_HOLD, $data['status'] );
- $this->assertArrayNotHasKey( 'status_label', $data );
- $this->assertArrayNotHasKey( 'related_orders', $data );
- $this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
+ $this->assertSame(
+ array(
+ 'id' => $contract->get_id(),
+ 'status' => 'on-hold',
+ ),
+ $response->get_data()
+ );
+ $this->assertSame( array( array( 'pause', $contract->get_id(), array( 'note' => 'Away' ) ) ), $this->calls );
+ }
+
+ /**
+ * Unknown contract, a contract of another customer, an unregistered action and a wrong
+ * `extension_slug` all get the same 404, and nothing runs.
+ */
+ public function test_post_hides_contracts_the_caller_cannot_act_on(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $foreign = $this->create_contract( $this->create_user( 'customer' ) );
+ $this->register_action( 'pause', 'manage_subscription_contract' );
+ wp_set_current_user( $customer_id );
+
+ $responses = array(
+ $this->run_action( 999999, 'pause' ),
+ $this->run_action( $foreign->get_id(), 'pause' ),
+ $this->run_action( $contract->get_id(), 'resume' ),
+ $this->run_action( $contract->get_id(), 'pause', array(), 'other-extension' ),
+ );
+
+ $not_found = $this->response_data( $responses[0] );
+ $this->assertSame( 'woocommerce_subscriptions_engine_contract_not_found', $not_found['code'] );
+ foreach ( $responses as $response ) {
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( $not_found, $response->get_data() );
+ }
+ $this->assertSame( array(), $this->calls );
+ }
+
+ /**
+ * Only the contract owner's action runs, even when another extension registered the same name.
+ */
+ public function test_dispatches_only_to_the_contract_owner(): void {
+ $this->register_action( 'pause', 'manage_woocommerce' );
+ ContractActions::register(
+ 'other-extension',
+ 'pause',
+ array(
+ 'callback' => function ( ContractView $contract ): ContractView {
+ $this->calls[] = array( 'other-extension', $contract->get_id(), array() );
+ return $contract;
+ },
+ 'permission' => 'manage_woocommerce',
+ )
+ );
+ $contract = $this->create_contract( null, 'other-extension' );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+
+ $this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+ $this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause', array(), 'other-extension' )->get_status() );
+ $this->assertSame( array( array( 'other-extension', $contract->get_id(), array() ) ), $this->calls );
}
- public function test_owner_reactivate_transitions_and_returns_the_domain_summary(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id, ContractStatus::ON_HOLD );
+ /**
+ * A capability permission is checked for the current user: `manage_woocommerce` admits store
+ * managers and hides the contract from its own customer.
+ */
+ public function test_capability_permission(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $this->register_action( 'pause', 'manage_woocommerce' );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/reactivate' ) );
+ wp_set_current_user( $customer_id );
+ $this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
- $this->assertSame( 200, $response->get_status() );
- $this->assertSame( ContractStatus::ACTIVE, $this->data_array( $response )['status'] );
- $this->assertSame( ContractStatus::ACTIVE, $this->reload( $id )->get_status() );
+ wp_set_current_user( $this->create_user( 'shop_manager' ) );
+ $this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+ }
+
+ /**
+ * A callable permission gets the contract and the request; anything but true is a 404.
+ */
+ public function test_callable_permission(): void {
+ $contract = $this->create_contract( null );
+ $seen = array();
+ $this->register_action(
+ 'pause',
+ static function ( ContractView $view, WP_REST_Request $request ) use ( &$seen ): bool {
+ $seen[] = array( $view->get_id(), $request->get_param( 'action' ) );
+ return 'yes' === $request->get_header( 'x-test-permission' );
+ }
+ );
+ wp_set_current_user( $this->create_user( 'customer' ) );
+
+ $this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+
+ $request = $this->action_request( $contract->get_id(), 'pause' );
+ $request->set_header( 'x-test-permission', 'yes' );
+ $this->assertSame( 200, rest_get_server()->dispatch( $request )->get_status() );
+ $this->assertSame( array( array( $contract->get_id(), 'pause' ), array( $contract->get_id(), 'pause' ) ), $seen );
}
- public function test_reactivate_on_an_already_active_contract_is_a_conflict(): void {
- // An active contract must never reach the date recompute (a past-due date
- // rolled forward would skip a charge); the guard maps to a 409.
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id, ContractStatus::ACTIVE );
- $before = $this->reload( $id )->get_next_payment_gmt();
+ /**
+ * An action that is not available for the contract is a 409, and nothing runs.
+ */
+ public function test_unavailable_action_is_a_conflict(): void {
+ $contract = $this->create_contract( null );
+ $this->register_action(
+ 'pause',
+ 'manage_woocommerce',
+ array(
+ 'is_available' => static function ( ContractView $view ): bool {
+ return 'on-hold' !== $view->get_status();
+ },
+ )
+ );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+ $this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/reactivate' ) );
+ $response = $this->run_action( $contract->get_id(), 'pause' );
$this->assertSame( 409, $response->get_status() );
- $this->assertSame( $before, $this->reload( $id )->get_next_payment_gmt(), 'The schedule is untouched.' );
+ $this->assertSame( 'woocommerce_subscriptions_engine_action_not_available', $this->response_data( $response )['code'] );
+ $this->assertCount( 1, $this->calls );
+ }
+
+ /**
+ * `action_args` are checked against the schema: required and typed properties reject with a
+ * 400, defaults fill in, and unknown keys are dropped.
+ */
+ public function test_action_args_are_validated_against_the_schema(): void {
+ $contract = $this->create_contract( null );
+ $this->register_action(
+ 'cancel',
+ 'manage_woocommerce',
+ array(
+ 'args' => array(
+ 'at_period_end' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ ),
+ 'reason' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+
+ $missing = $this->run_action( $contract->get_id(), 'cancel' );
+ $this->assertSame( 400, $missing->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_invalid_action_args', $this->response_data( $missing )['code'] );
+
+ $mistyped = $this->run_action(
+ $contract->get_id(),
+ 'cancel',
+ array(
+ 'reason' => 'Moving',
+ 'at_period_end' => 'sometimes',
+ )
+ );
+ $this->assertSame( 400, $mistyped->get_status() );
+ $this->assertCount( 0, $this->calls );
+
+ $response = $this->run_action(
+ $contract->get_id(),
+ 'cancel',
+ array(
+ 'reason' => 'Moving',
+ 'extra' => 'dropped',
+ )
+ );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame(
+ array(
+ 'reason' => 'Moving',
+ 'at_period_end' => true,
+ ),
+ $this->get_recorded_action_args( 0 )
+ );
+
+ $this->assertSame(
+ 200,
+ $this->run_action(
+ $contract->get_id(),
+ 'cancel',
+ array(
+ 'reason' => 'Moving',
+ 'at_period_end' => 'false',
+ )
+ )->get_status()
+ );
+ $this->assertFalse( $this->get_recorded_action_args( 1 )['at_period_end'] );
+ }
+
+ /**
+ * A callable `args` resolves per contract, the same way for discovery and for running.
+ */
+ public function test_callable_args_resolve_per_contract(): void {
+ $contract = $this->create_contract( null );
+ $this->register_action(
+ 'cancel',
+ 'manage_woocommerce',
+ array(
+ 'args' => static function ( ContractView $view ): array {
+ return array(
+ 'at_period_end' => array(
+ 'type' => 'boolean',
+ 'default' => 'active' === $view->get_status(),
+ ),
+ );
+ },
+ )
+ );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+
+ $actions = $this->response_data( $this->list_actions( $contract->get_id() ) )['actions'];
+ $this->assertEquals(
+ array(
+ array(
+ 'action' => 'cancel',
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'description' => '',
+ 'args' => (object) array(
+ 'at_period_end' => array(
+ 'type' => 'boolean',
+ 'default' => true,
+ 'required' => false,
+ ),
+ ),
+ ),
+ ),
+ $actions
+ );
+
+ $this->assertSame( 200, $this->run_action( $contract->get_id(), 'cancel' )->get_status() );
+ $this->assertSame( array( 'at_period_end' => true ), $this->get_recorded_action_args( 0 ) );
+ }
+
+ /**
+ * A callback `WP_Error` passes through with its status, or 400 without one.
+ */
+ public function test_callback_errors_pass_through(): void {
+ $contract = $this->create_contract( null );
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ 'teapot',
+ array(
+ 'callback' => static function (): WP_Error {
+ return new WP_Error( 'teapot', 'Short and stout.', array( 'status' => 418 ) );
+ },
+ 'permission' => 'manage_woocommerce',
+ )
+ );
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ 'refuse',
+ array(
+ 'callback' => static function (): WP_Error {
+ return new WP_Error( 'refused', 'No.' );
+ },
+ 'permission' => 'manage_woocommerce',
+ )
+ );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+
+ $teapot = $this->run_action( $contract->get_id(), 'teapot' );
+ $this->assertSame( 418, $teapot->get_status() );
+ $this->assertSame( 'teapot', $this->response_data( $teapot )['code'] );
+
+ $refused = $this->run_action( $contract->get_id(), 'refuse' );
+ $this->assertSame( 400, $refused->get_status() );
+ $this->assertSame( 'refused', $this->response_data( $refused )['code'] );
+ }
+
+ /**
+ * A throwing callback, availability check or permission callable is a generic 500.
+ */
+ public function test_throwing_extension_callables_are_a_server_error(): void {
+ $contract = $this->create_contract( null );
+ $throw = static function (): bool {
+ throw new RuntimeException( 'Boom.' );
+ };
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ 'callback',
+ array(
+ 'callback' => $throw,
+ 'permission' => 'manage_woocommerce',
+ )
+ );
+ $this->register_action( 'availability', 'manage_woocommerce', array( 'is_available' => $throw ) );
+ $this->register_action( 'permission', $throw );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+
+ foreach ( array( 'callback', 'availability', 'permission' ) as $action ) {
+ $response = $this->run_action( $contract->get_id(), $action );
+ $this->assertSame( 500, $response->get_status(), $action );
+ $this->assertSame( 'woocommerce_subscriptions_engine_action_failed', $this->response_data( $response )['code'] );
+ }
}
- public function test_cancel_at_period_end_winds_down_the_contract(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id );
+ /**
+ * Discovery lists the owner's available actions to a store manager, whatever their permission.
+ */
+ public function test_discovery_lists_available_actions(): void {
+ $contract = $this->create_contract( $this->create_user( 'customer' ) );
+ $this->register_action( 'pause', 'manage_subscription_contract', array( 'description' => 'Pause deliveries.' ) );
+ $this->register_action( 'resume', 'manage_subscription_contract', array( 'is_available' => '__return_false' ) );
+ $this->register_action( 'refund', '__return_false' );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
- $request = new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/cancel' );
- $request->set_body_params( array( 'at_period_end' => true ) );
- $response = rest_get_server()->dispatch( $request );
+ $response = $this->list_actions( $contract->get_id() );
$this->assertSame( 200, $response->get_status() );
- // The summary status tells the caller which cancel mode landed.
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->data_array( $response )['status'] );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->reload( $id )->get_status() );
+ $this->assertEquals(
+ array(
+ 'actions' => array(
+ array(
+ 'action' => 'pause',
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'description' => 'Pause deliveries.',
+ 'args' => new \stdClass(),
+ ),
+ array(
+ 'action' => 'refund',
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'description' => '',
+ 'args' => new \stdClass(),
+ ),
+ ),
+ ),
+ $response->get_data()
+ );
+ $this->assertStringContainsString( '"args":{}', (string) wp_json_encode( $response->get_data() ), 'No args encode as an empty JSON object.' );
+ $this->assertSame( 404, $this->list_actions( 999999 )->get_status() );
}
- public function test_cancel_now_terminates_the_contract(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id, ContractStatus::ON_HOLD );
+ /**
+ * Discovery has the contract read's permission: its customer lists the actions, guests get a
+ * 401 and other customers a 404.
+ */
+ public function test_discovery_needs_read_subscription_contract(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $this->register_action( 'pause', 'manage_woocommerce' );
- $request = new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/cancel' );
- $request->set_body_params( array( 'at_period_end' => false ) );
- $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 401, $this->list_actions( $contract->get_id() )->get_status() );
+ wp_set_current_user( $customer_id );
+ $response = $this->list_actions( $contract->get_id() );
$this->assertSame( 200, $response->get_status() );
- $this->assertSame( ContractStatus::CANCELLED, $this->data_array( $response )['status'] );
- $this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status() );
+ $actions = $this->response_data( $response )['actions'];
+ $this->assertIsArray( $actions );
+ $this->assertSame( array( 'pause' ), array_column( $actions, 'action' ) );
+
+ wp_set_current_user( $this->create_user( 'customer' ) );
+ $this->assertSame( 404, $this->list_actions( $contract->get_id() )->get_status() );
+ }
+
+ /**
+ * An action registered after the routes still dispatches.
+ */
+ public function test_actions_registered_after_rest_api_init_dispatch(): void {
+ $contract = $this->create_contract( null );
+ wp_set_current_user( $this->create_user( 'administrator' ) );
+ $this->assertSame( 404, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+
+ $this->register_action( 'pause', 'manage_woocommerce' );
+
+ $this->assertSame( 200, $this->run_action( $contract->get_id(), 'pause' )->get_status() );
+ }
+
+ /**
+ * Register a test-extension action whose callback records the call and puts the contract on hold.
+ *
+ * @param string $action Action slug.
+ * @param string|callable $permission Permission.
+ * @param array<string, mixed> $extra Further registration args.
+ */
+ private function register_action( string $action, $permission, array $extra = array() ): void {
+ ContractActions::register(
+ self::EXTENSION_SLUG,
+ $action,
+ array(
+ 'callback' => function ( ContractView $contract, array $action_args ) use ( $action ): ?ContractView {
+ $this->calls[] = array( $action, $contract->get_id(), $action_args );
+ return Contracts::update( $contract->get_id(), array( 'status' => 'on-hold' ) );
+ },
+ 'permission' => $permission,
+ ) + $extra
+ );
}
- public function test_illegal_transition_is_a_conflict(): void {
- // There is no status state machine: each flow guards its own preconditions, and
- // a precondition the current state does not meet (holding a cancelled contract)
- // surfaces as a 409.
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id, ContractStatus::CANCELLED );
+ /**
+ * The `action_args` an action callback received, by call order.
+ *
+ * @param int $index Call index.
+ * @return array<string, mixed>
+ */
+ private function get_recorded_action_args( int $index ): array {
+ $this->assertArrayHasKey( $index, $this->calls );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+ return $this->calls[ $index ][2];
+ }
- $this->assertSame( 409, $response->get_status() );
+ /**
+ * An active contract.
+ *
+ * @param int|null $customer_id Customer user id.
+ * @param string $extension_slug Owning extension.
+ */
+ private function create_contract( ?int $customer_id, string $extension_slug = self::EXTENSION_SLUG ): ContractView {
+ return Contracts::create(
+ array(
+ 'extension_slug' => $extension_slug,
+ 'status' => 'active',
+ 'customer_id' => $customer_id,
+ )
+ );
}
- public function test_hold_on_an_expired_contract_is_a_conflict(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id, ContractStatus::EXPIRED );
+ /**
+ * Build a POST to the action route.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $action Action slug.
+ * @param array<string, mixed> $action_args Action args.
+ * @param string $extension_slug Extension slug sent in the body.
+ */
+ private function action_request( int $contract_id, string $action, array $action_args = array(), string $extension_slug = self::EXTENSION_SLUG ): WP_REST_Request {
+ $request = new WP_REST_Request( 'POST', self::BASE . '/' . $contract_id . '/action' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ (string) wp_json_encode(
+ array(
+ 'action' => $action,
+ 'extension_slug' => $extension_slug,
+ 'action_args' => (object) $action_args,
+ )
+ )
+ );
- $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+ return $request;
+ }
- $this->assertSame( 409, $response->get_status() );
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::EXPIRED, $stored->get_status() );
- $this->assertSame( '2099-02-01 00:00:00', $stored->get_next_payment_gmt(), 'The rejected action writes nothing.' );
+ /**
+ * POST an action.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $action Action slug.
+ * @param array<string, mixed> $action_args Action args.
+ * @param string $extension_slug Extension slug sent in the body.
+ */
+ private function run_action( int $contract_id, string $action, array $action_args = array(), string $extension_slug = self::EXTENSION_SLUG ): WP_REST_Response {
+ return rest_get_server()->dispatch( $this->action_request( $contract_id, $action, $action_args, $extension_slug ) );
}
- public function test_cancel_on_an_expired_contract_is_a_conflict(): void {
- wp_set_current_user( $this->owner_id );
- $id = $this->seed( $this->owner_id, ContractStatus::EXPIRED );
+ /**
+ * GET the action list.
+ *
+ * @param int $contract_id Contract id.
+ */
+ private function list_actions( int $contract_id ): WP_REST_Response {
+ return rest_get_server()->dispatch( new WP_REST_Request( 'GET', self::BASE . '/' . $contract_id . '/action' ) );
+ }
- $request = new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/cancel' );
- $request->set_body_params( array( 'at_period_end' => false ) );
- $response = rest_get_server()->dispatch( $request );
+ /**
+ * Create a user with a role.
+ *
+ * @param string $role Role slug.
+ */
+ private function create_user( string $role ): int {
+ $user_id = self::factory()->user->create( array( 'role' => $role ) );
+ $this->assertIsInt( $user_id );
- $this->assertSame( 409, $response->get_status() );
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::EXPIRED, $stored->get_status() );
- $this->assertSame( '2099-02-01 00:00:00', $stored->get_next_payment_gmt(), 'The rejected action writes nothing.' );
+ return $user_id;
}
/**
- * The response body as an array (asserts it is one, narrowing offset access).
+ * Get response data as an array.
*
- * @param WP_REST_Response $response The dispatched response.
- * @return array<int|string, mixed>
+ * @param WP_REST_Response $response Response.
+ * @return array<array-key, mixed>
*/
- private function data_array( WP_REST_Response $response ): array {
+ private function response_data( WP_REST_Response $response ): array {
$data = $response->get_data();
$this->assertIsArray( $data );
@@ -263,14 +725,11 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
}
/**
- * Reload a contract, asserting it still exists (narrows the nullable read).
+ * Dispatch a GET for one contract.
*
- * @param int $id Contract id.
+ * @param int $contract_id Contract id.
*/
- private function reload( int $id ): Contract {
- $contract = $this->contracts->find( $id );
- $this->assertInstanceOf( Contract::class, $contract );
-
- return $contract;
+ private function get( int $contract_id ): WP_REST_Response {
+ return rest_get_server()->dispatch( new WP_REST_Request( 'GET', self::BASE . '/' . $contract_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 8885475dacd..4b39432aed5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -1,6 +1,6 @@
<?php
/**
- * Integration tests for the interim Subscriptions lifecycle and renewal facade.
+ * Integration tests for the interim Subscriptions renewal facade.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -15,7 +15,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
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\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
@@ -27,7 +26,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepos
class SubscriptionsTest extends EngineIntegrationTestCase {
/**
- * Gateway id used for the lifecycle charge - declares `recurring` and completes
+ * Gateway id used for the renewal charge - declares `recurring` and completes
* the charge inline (the dummy-gateway shape), matching the real gateway used in CI.
*/
private const GATEWAY = 'dummy';
@@ -202,13 +201,6 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
$this->assertSame( array( 2, 4, 6 ), $limits, 'The linked-order query is bounded to offset + limit.' );
}
- /**
- * @testdox cancel returns false for an unknown contract.
- */
- public function test_cancel_unknown_contract_returns_false(): void {
- $this->assertFalse( Subscriptions::cancel( 999999 ) );
- }
-
/**
* @testdox renew_now returns null for an unknown contract.
*/
@@ -217,9 +209,9 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
}
/**
- * @testdox The full lifecycle runs through the facade: buy, renew, cancel.
+ * @testdox A purchased contract renews through the facade.
*/
- public function test_full_lifecycle_buy_renew_cancel(): void {
+ public function test_buy_then_renew_through_the_facade(): void {
// Buy: signup builds cycle 1 (billed).
$contract = $this->sign_up_contract();
$contract_id = $contract->get_id();
@@ -245,49 +237,5 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
$after_renew = Contracts::get( $contract_id );
$this->assertInstanceOf( ContractView::class, $after_renew );
$this->assertSame( '2026-03-15 00:00:00', $after_renew->get_next_payment_gmt() );
-
- // Cancel: the contract goes terminal.
- $this->assertTrue( Subscriptions::cancel( $contract_id ) );
-
- $after_cancel = Contracts::get( $contract_id );
- $this->assertInstanceOf( ContractView::class, $after_cancel );
- $this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
- }
-
- /**
- * @testdox the lifecycle verbs return false for an unknown contract.
- */
- public function test_lifecycle_actions_return_false_for_an_unknown_contract(): void {
- $this->assertFalse( Subscriptions::hold( 987654 ) );
- $this->assertFalse( Subscriptions::reactivate( 987654 ) );
- $this->assertFalse( Subscriptions::cancel_at_period_end( 987654 ) );
- }
-
- /**
- * @testdox the portal lifecycle runs through the facade: hold, reactivate, cancel at period end.
- */
- public function test_portal_lifecycle_hold_reactivate_cancel_at_period_end(): void {
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $this->assertTrue( Subscriptions::hold( $contract_id ) );
- $held = Contracts::get( $contract_id );
- $this->assertInstanceOf( ContractView::class, $held );
- $this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
- $this->assertNull( $held->get_next_payment_gmt(), 'Hold disarms the next-due moment.' );
-
- $this->assertTrue( Subscriptions::reactivate( $contract_id ) );
- $active = Contracts::get( $contract_id );
- $this->assertInstanceOf( ContractView::class, $active );
- $this->assertSame( ContractStatus::ACTIVE, $active->get_status() );
- $this->assertNotNull( $active->get_next_payment_gmt(), 'Reactivate re-arms the next-due moment.' );
-
- $this->assertTrue( Subscriptions::cancel_at_period_end( $contract_id ) );
- $pending = Contracts::get( $contract_id );
- $this->assertInstanceOf( ContractView::class, $pending );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $pending->get_status() );
- $this->assertNull( $pending->get_next_payment_gmt(), 'Cancel at period end disarms the next-due moment.' );
- $this->assertNotNull( $pending->get_end_gmt(), 'The former next-due moment becomes the end date.' );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
deleted file mode 100644
index 78f598cfa2d..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
+++ /dev/null
@@ -1,440 +0,0 @@
-<?php
-/**
- * Integration tests for the Cancellation contract operation: the period-end mode (->
- * PENDING_CANCELLATION, the end date stamped, the next-due moment disarmed) and the
- * immediate mode's guards and disarm (its order/cycle effects are covered through the
- * facade suite).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
-
-use DomainException;
-use EngineIntegrationTestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation
- */
-class CancellationTest extends EngineIntegrationTestCase {
-
- /**
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * @var Cancellation
- */
- private $sut;
-
- public function set_up(): void {
- parent::set_up();
- $this->contracts = new ContractRepository();
- $this->sut = new Cancellation( $this->contracts );
- }
-
- /**
- * Seed an active contract with a future next-payment date.
- *
- * @param string|null $end_gmt Optional pre-set end date.
- */
- private function seed_active( ?string $end_gmt = null ): int {
- return $this->seed( ContractStatus::ACTIVE, $end_gmt );
- }
-
- /**
- * Seed a contract at a status with a future next-payment date.
- *
- * @param string $status Contract status.
- * @param string|null $end_gmt Optional pre-set end date.
- */
- private function seed( string $status, ?string $end_gmt = null ): int {
- $contract = Contract::create(
- array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => 1,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2099-01-01 00:00:00',
- 'end_gmt' => $end_gmt,
- 'billing_total' => '19.99',
- )
- );
-
- return $this->contracts->insert( $contract );
- }
-
- public function test_winds_down_to_pending_cancellation_and_stamps_the_end_date(): void {
- $id = $this->seed_active();
-
- $result = $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $this->assertTrue( $result );
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
- // The next-payment moment becomes the contract end (the "cancels on" date).
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
- }
-
- public function test_cancel_at_period_end_stamps_the_end_and_clears_the_next_payment(): void {
- // The flow disarms the next-due moment itself, so the due scan never selects the
- // winding-down contract; its end_gmt is the date it terminates at.
- $id = $this->seed_active();
-
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- }
-
- public function test_preserves_an_existing_end_date(): void {
- $id = $this->seed_active( '2026-09-09 00:00:00' );
-
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( '2026-09-09 00:00:00', $stored->get_end_gmt() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- }
-
- public function test_cancel_at_period_end_from_on_hold_stamps_the_end_from_the_hold_anchor(): void {
- $id = $this->seed_active();
- ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-
- $this->assertTrue( $this->sut->cancel_at_period_end( $this->reload( $id ) ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- public function test_cancel_at_period_end_on_a_pending_cancellation_contract_is_a_no_op(): void {
- $id = $this->seed_active();
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $fired = 0;
- add_action(
- Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- $this->assertTrue( $this->sut->cancel_at_period_end( $this->reload( $id ) ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( 1, $fired );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- }
-
- public function test_fires_the_pending_cancellation_action(): void {
- $id = $this->seed_active();
- $fired = 0;
- add_action(
- Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $this->assertSame( 1, $fired );
- }
-
- public function test_rejects_a_terminal_contract(): void {
- $contract = Contract::create(
- array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => 1,
- 'status' => ContractStatus::CANCELLED,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'billing_total' => '19.99',
- )
- );
- $id = $this->contracts->insert( $contract );
-
- $this->expectException( DomainException::class );
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
- }
-
- public function test_cancel_clears_the_next_payment(): void {
- $id = $this->seed_active();
-
- $this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- }
-
- public function test_cancel_clears_the_next_payment_and_hold_anchor_of_a_held_contract(): void {
- $id = $this->seed_active();
- ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-
- $this->sut->cancel( $this->reload( $id ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- /**
- * The anchor is cleared after the status write has committed, so a failed delete must
- * not abort the transition: the lifecycle action still fires.
- *
- * @dataProvider provide_cancel_modes
- *
- * @param string $method Cancellation method under test.
- * @param string $action Action the method fires.
- * @param string $status Status the method writes.
- */
- public function test_a_failed_anchor_clear_does_not_abort_the_transition( string $method, string $action, string $status ): void {
- $id = $this->seed_active();
- ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
-
- $fired = 0;
- add_action(
- $action,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
- $break = $this->break_meta_deletes();
-
- try {
- $this->assertTrue( $this->sut->$method( $this->reload( $id ) ) );
- } finally {
- remove_filter( 'query', $break );
- }
-
- $this->assertSame( 1, $fired, 'The lifecycle action fires.' );
- $this->assertSame( $status, $this->reload( $id )->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ), 'The anchor is left behind.' );
- }
-
- /**
- * A transition that loses its compare-and-set leaves the hold anchor in place, so the
- * contract that won can still resume from it.
- *
- * @dataProvider provide_cancel_modes
- *
- * @param string $method Cancellation method under test.
- */
- public function test_a_lost_race_keeps_the_hold_anchor( string $method ): void {
- $id = $this->seed_active();
- ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
- $stale = $this->reload( $id );
-
- $concurrent = $this->reload( $id );
- $concurrent->set_status( ContractStatus::EXPIRED );
- $this->contracts->update( $concurrent );
-
- try {
- $this->sut->$method( $stale );
- $this->fail( 'Expected a DomainException when the conditional write misses.' );
- } catch ( DomainException $e ) {
- $this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
- }
-
- /**
- * Both cancellation modes: method, action, resulting status.
- *
- * @return array<string, array{0: string, 1: string, 2: string}>
- */
- public function provide_cancel_modes(): array {
- return array(
- 'cancel' => array( 'cancel', Cancellation::CONTRACT_CANCELLED_ACTION, ContractStatus::CANCELLED ),
- 'cancel at period end' => array( 'cancel_at_period_end', Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION, ContractStatus::PENDING_CANCELLATION ),
- );
- }
-
- /**
- * Make every contract meta DELETE fail until the returned filter is removed.
- *
- * @return callable The `query` filter to remove.
- */
- private function break_meta_deletes(): callable {
- $meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
- $break = static function ( string $query ) use ( $meta_table ): string {
- return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
- };
- add_filter( 'query', $break );
-
- return $break;
- }
-
- public function test_cancel_accepts_a_pending_cancellation_contract(): void {
- $id = $this->seed( ContractStatus::PENDING_CANCELLATION );
-
- $this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- }
-
- public function test_cancel_accepts_a_draft_contract(): void {
- // A stuck draft (created, never activated) has no cycles and no due moment.
- $id = Contracts::create(
- array(
- 'extension_slug' => 'test-owner',
- 'customer_id' => 1,
- 'currency' => 'USD',
- )
- )->get_id();
- $this->assertSame( ContractStatus::DRAFT, $this->reload( $id )->get_status() );
-
- $fired = 0;
- add_action(
- Cancellation::CONTRACT_CANCELLED_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- $this->assertTrue( Subscriptions::cancel( $id ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
- $this->assertNull( $stored->get_next_payment_gmt() );
- $this->assertSame( 1, $fired );
- }
-
- public function test_cancel_on_a_cancelled_contract_is_a_no_op_that_refires_the_action(): void {
- $id = $this->seed( ContractStatus::CANCELLED );
-
- $fired = 0;
- add_action(
- Cancellation::CONTRACT_CANCELLED_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- $this->assertTrue( $this->sut->cancel( $this->reload( $id ) ) );
-
- $this->assertSame( 1, $fired );
- $this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status() );
- }
-
- public function test_cancel_rejects_an_expired_contract(): void {
- $id = $this->seed( ContractStatus::EXPIRED );
- $before = did_action( Cancellation::CONTRACT_CANCELLED_ACTION );
-
- try {
- $this->sut->cancel( $this->reload( $id ) );
- $this->fail( 'Expected a DomainException for an expired contract.' );
- } catch ( DomainException $e ) {
- $this->assertSame( ContractStatus::EXPIRED, $this->reload( $id )->get_status() );
- $this->assertSame( $before, did_action( Cancellation::CONTRACT_CANCELLED_ACTION ), 'The cancelled action does not fire.' );
- }
- }
-
- public function test_cancel_rejects_an_unregistered_stored_status(): void {
- global $wpdb;
-
- $id = $this->seed_active();
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
- try {
- $this->sut->cancel( $this->reload( $id ) );
- $this->fail( 'Expected a DomainException for an unregistered stored status.' );
- } catch ( DomainException $e ) {
- $this->assertSame( 'legacy-paused', $this->reload( $id )->get_status() );
- }
- }
-
- public function test_cancel_at_period_end_ignores_a_malformed_hold_anchor(): void {
- $id = $this->seed( ContractStatus::ON_HOLD );
- $this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );
-
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt(), 'The stored next payment, not the malformed anchor, is the period end.' );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- public function test_cancel_at_period_end_from_on_hold_without_a_date_leaves_no_end(): void {
- $id = $this->seed( ContractStatus::ON_HOLD );
- $held = $this->reload( $id );
- $held->set_next_payment_gmt( null );
- $this->contracts->update( $held );
- $this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );
-
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
- $this->assertNull( $stored->get_end_gmt(), 'A malformed anchor is never written as the end date.' );
- }
-
- public function test_cancel_at_period_end_rejects_an_expired_contract(): void {
- $id = $this->seed( ContractStatus::EXPIRED );
- $before = did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION );
-
- try {
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
- $this->fail( 'Expected a DomainException for an expired contract.' );
- } catch ( DomainException $e ) {
- $reloaded = $this->reload( $id );
- $this->assertSame( ContractStatus::EXPIRED, $reloaded->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $reloaded->get_next_payment_gmt(), 'Nothing was written.' );
- $this->assertSame( $before, did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION ), 'The action does not fire.' );
- }
- }
-
- public function test_cancel_at_period_end_rejects_an_unregistered_stored_status(): void {
- global $wpdb;
-
- $id = $this->seed_active();
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
- try {
- $this->sut->cancel_at_period_end( $this->reload( $id ) );
- $this->fail( 'Expected a DomainException for an unregistered stored status.' );
- } catch ( DomainException $e ) {
- $reloaded = $this->reload( $id );
- $this->assertSame( 'legacy-paused', $reloaded->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $reloaded->get_next_payment_gmt(), 'Nothing was written.' );
- }
- }
-
- /**
- * Reload a contract, asserting it still exists (narrows the nullable read).
- *
- * @param int $id Contract id.
- */
- private function reload( int $id ): Contract {
- $contract = $this->contracts->find( $id );
- $this->assertInstanceOf( Contract::class, $contract );
-
- return $contract;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
deleted file mode 100644
index 1973b59a226..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
+++ /dev/null
@@ -1,241 +0,0 @@
-<?php
-/**
- * Integration tests for the Hold contract operation: ACTIVE -> ON_HOLD, the
- * next-due moment disarmed (kept as the hold anchor), and the held action fired.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
-
-use DomainException;
-use EngineIntegrationTestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold
- */
-class HoldTest extends EngineIntegrationTestCase {
-
- /**
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * @var Hold
- */
- private $sut;
-
- public function set_up(): void {
- parent::set_up();
- $this->contracts = new ContractRepository();
- $this->sut = new Hold( $this->contracts );
- }
-
- /**
- * Seed a contract at a status with a next-payment date (a future one by default).
- *
- * @param string $status Contract status.
- * @param string|null $next_payment Next-payment GMT string, or null.
- */
- private function seed( string $status, ?string $next_payment = '2099-01-01 00:00:00' ): int {
- $contract = Contract::create(
- array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => 1,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => $next_payment,
- 'billing_total' => '19.99',
- )
- );
-
- return $this->contracts->insert( $contract );
- }
-
- /**
- * The stored hold anchor for a contract ('' when absent).
- *
- * @param int $id Contract id.
- * @return mixed
- */
- private function anchor( int $id ) {
- return $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true );
- }
-
- public function test_hold_suspends_billing_by_moving_to_on_hold(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
-
- $result = $this->sut->hold( $this->reload( $id ) );
-
- $this->assertTrue( $result );
- // No charge while held: hold disarms the next-due moment the batch due scan keys on.
- $this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
- }
-
- public function test_hold_clears_the_next_payment_and_keeps_the_anchor(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
-
- $this->sut->hold( $this->reload( $id ) );
-
- $held = $this->reload( $id );
- $this->assertNull( $held->get_next_payment_gmt() );
- $this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
- }
-
- public function test_hold_without_a_next_payment_stores_no_anchor(): void {
- $id = $this->seed( ContractStatus::ACTIVE, null );
-
- $this->sut->hold( $this->reload( $id ) );
-
- $held = $this->reload( $id );
- $this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
- $this->assertNull( $held->get_next_payment_gmt() );
- $this->assertSame( '', $this->anchor( $id ) );
- }
-
- public function test_hold_on_an_on_hold_contract_is_an_idempotent_no_op(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
- $this->sut->hold( $this->reload( $id ) );
-
- $fired = 0;
- add_action(
- Hold::CONTRACT_HELD_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- // A second hold must not wipe the anchor stashed by the first.
- $this->assertTrue( $this->sut->hold( $this->reload( $id ) ) );
-
- $held = $this->reload( $id );
- $this->assertSame( 1, $fired );
- $this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
- $this->assertNull( $held->get_next_payment_gmt() );
- $this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
- }
-
- public function test_the_anchor_survives_a_whole_contract_update(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
- $this->sut->hold( $this->reload( $id ) );
-
- $held = $this->reload( $id );
- $held->set_end_gmt( '2099-12-31 00:00:00' );
- $this->contracts->update( $held );
-
- $this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
- $this->assertSame( '2099-01-01 00:00:00', Hold::read_anchor( $this->contracts, $id ) );
- }
-
- public function test_hold_fires_the_held_action(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
- $fired = 0;
- add_action(
- Hold::CONTRACT_HELD_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- $this->sut->hold( $this->reload( $id ) );
-
- $this->assertSame( 1, $fired );
- }
-
- public function test_hold_loses_the_race_to_a_concurrent_transition(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
- $contract = $this->reload( $id );
-
- // A concurrent request cancels the contract after our read: the compare-and-set
- // write must miss loudly instead of resurrecting the contract to on-hold.
- $concurrent = $this->reload( $id );
- $concurrent->set_status( ContractStatus::CANCELLED );
- $this->contracts->update( $concurrent );
-
- try {
- $this->sut->hold( $contract );
- $this->fail( 'Expected a DomainException when the conditional write misses.' );
- } catch ( DomainException $e ) {
- $this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status(), 'The concurrent cancel is not clobbered.' );
- }
- }
-
- /**
- * The anchor is stored and read back before the hold disarms the contract, so a failed
- * meta write aborts the hold with nothing disarmed instead of leaving a held contract
- * that reactivation could not re-arm.
- */
- public function test_hold_aborts_without_disarming_when_the_anchor_cannot_be_stored(): void {
- $id = $this->seed( ContractStatus::ACTIVE );
- $meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
- $break = static function ( string $query ) use ( $meta_table ): string {
- return 0 === strpos( $query, "INSERT INTO `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
- };
- add_filter( 'query', $break );
-
- try {
- $this->sut->hold( $this->reload( $id ) );
- $this->fail( 'Expected the hold to abort when the anchor cannot be stored.' );
- } catch ( \RuntimeException $e ) {
- $this->assertStringContainsString( 'anchor could not be stored', $e->getMessage() );
- } finally {
- remove_filter( 'query', $break );
- }
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'The next-due moment was not disarmed.' );
- }
-
- public function test_hold_rejects_a_cancelled_contract(): void {
- $id = $this->seed( ContractStatus::CANCELLED );
-
- $this->expectException( DomainException::class );
- $this->sut->hold( $this->reload( $id ) );
- }
-
- public function test_hold_rejects_a_pending_cancellation_contract(): void {
- $id = $this->seed( ContractStatus::PENDING_CANCELLATION );
-
- $this->expectException( DomainException::class );
- $this->sut->hold( $this->reload( $id ) );
- }
-
- public function test_hold_rejects_an_unregistered_stored_status(): void {
- global $wpdb;
-
- $id = $this->seed( ContractStatus::ACTIVE );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
- try {
- $this->sut->hold( $this->reload( $id ) );
- $this->fail( 'Expected a DomainException for an unregistered stored status.' );
- } catch ( DomainException $e ) {
- $this->assertSame( 'legacy-paused', $this->reload( $id )->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
- }
- }
-
- /**
- * Reload a contract, asserting it still exists (narrows the nullable read).
- *
- * @param int $id Contract id.
- */
- private function reload( int $id ): Contract {
- $contract = $this->contracts->find( $id );
- $this->assertInstanceOf( Contract::class, $contract );
-
- return $contract;
- }
-}
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
deleted file mode 100644
index a1fccbae5c2..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ /dev/null
@@ -1,511 +0,0 @@
-<?php
-/**
- * Integration tests for the Reactivation contract operation: ON_HOLD -> ACTIVE and the
- * next-payment date recomputed forward (the Model-1 seam), which re-arms the batch due
- * scan (an active contract carrying a next-payment date).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Contracts;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use DomainException;
-use EngineIntegrationTestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-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\SchemaInstaller;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation
- */
-class ReactivationTest extends EngineIntegrationTestCase {
-
- private const GATEWAY = 'engine_test_gateway';
-
- /**
- * Selling-plan id that resolves to no plan row, to exercise the no-policy floor.
- */
- private const MISSING_PLAN_ID = 999999;
-
- /**
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * @var Reactivation
- */
- private $sut;
-
- public function set_up(): void {
- parent::set_up();
-
- $this->contracts = new ContractRepository();
- $this->sut = new Reactivation( $this->contracts );
- }
-
- /**
- * Create a monthly plan and return its id.
- */
- private function make_monthly_plan(): int {
- return $this->make_plan();
- }
-
- /**
- * Seed a contract with a next-payment date and the given selling plan.
- *
- * An on-hold row that still carries `next_payment_gmt` and no hold anchor meta is the
- * shape of a contract held before hold started disarming the next-due moment; it is the
- * reactivation fallback these seeds exercise.
- *
- * @param string|null $next_payment_gmt Next-payment GMT string, or null.
- * @param int $selling_plan_id Selling plan id.
- * @param string $status Contract status. Default ON_HOLD.
- * @param array<string, string> $meta Contract meta.
- */
- private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD, array $meta = array() ): int {
- $contract = Contract::create(
- array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => 1,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => $selling_plan_id,
- 'payment_method' => self::GATEWAY,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => $next_payment_gmt,
- 'billing_total' => '19.99',
- )
- );
-
- $id = $this->contracts->insert( $contract );
- foreach ( $meta as $key => $value ) {
- $this->contracts->add_meta( $id, $key, $value );
- }
-
- return $id;
- }
-
- private function utc( string $datetime ): DateTimeImmutable {
- return new DateTimeImmutable( $datetime, new DateTimeZone( 'UTC' ) );
- }
-
- public function test_reactivate_resumes_and_rearms_the_renewal(): void {
- $id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
-
- $result = $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
- $this->assertTrue( $result );
- $stored = $this->reload( $id );
- // Re-armed: an active contract carrying a next-payment date is what the batch due scan picks up.
- $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
- $this->assertNotNull( $stored->get_next_payment_gmt(), 'Reactivate re-arms: the contract is active with a next-payment date.' );
- }
-
- public function test_reactivate_keeps_a_future_next_payment_unchanged(): void {
- // Held, then resumed before the date arrives: nothing to recompute.
- $id = $this->seed_on_hold( '2026-07-01 00:00:00', $this->make_monthly_plan() );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-15 00:00:00' ) );
-
- $this->assertSame( '2026-07-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
- }
-
- public function test_reactivate_rolls_a_past_due_date_forward_by_whole_cadences(): void {
- // Due 2026-02-01, held until 2026-04-15: roll +1 month until future ->
- // 2026-02-01 -> 2026-03-01 -> 2026-04-01 -> 2026-05-01 (first > now).
- $id = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_monthly_plan() );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
-
- $this->assertSame( '2026-05-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
- }
-
- public function test_reactivate_rearms_from_the_hold_anchor(): void {
- $id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ACTIVE );
- ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
- $this->assertNull( $this->reload( $id )->get_next_payment_gmt(), 'Hold disarmed the next-due moment.' );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- public function test_reactivate_rolls_a_past_due_anchor_forward(): void {
- // Held with a 2026-02-01 anchor, resumed 2026-04-15: rolled forward by whole
- // cadences exactly like a stored past-due date -> 2026-05-01.
- $id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2026-02-01 00:00:00' ) );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( '2026-05-01 00:00:00', $stored->get_next_payment_gmt() );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- /**
- * Hold clears the next payment, so one set while held was re-armed deliberately and wins.
- */
- public function test_reactivate_prefers_a_stored_next_payment_over_the_anchor(): void {
- $id = $this->seed_on_hold( '2099-06-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( '2099-06-01 00:00:00', $stored->get_next_payment_gmt() );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- public function test_reactivate_ignores_a_malformed_anchor(): void {
- $id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => 'not-a-date' ) );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
- $this->assertNull( $stored->get_next_payment_gmt(), 'A malformed anchor counts as absent.' );
- $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
-
- public function test_reactivate_rolls_by_the_frozen_snapshot_cadence_over_the_live_plan(): void {
- // The live selling plan is monthly, but the contract's frozen terms are
- // yearly: the snapshot is what the contract bills under, so the forward
- // roll steps by years - 2026-01-15 -> 2027-01-15 (first > now).
- $id = $this->seed_on_hold( '2026-01-15 00:00:00', $this->make_monthly_plan() );
-
- $contract = $this->reload( $id );
- $contract->set_plan_snapshot(
- PlanSnapshot::from_array(
- array(
- 'selling_plan_id' => $contract->get_selling_plan_id(),
- 'billing_policy' => array(
- 'period' => 'year',
- 'interval' => 1,
- ),
- )
- )
- );
-
- $this->sut->reactivate( $contract, $this->utc( '2026-03-01 00:00:00' ) );
-
- $this->assertSame( '2027-01-15 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
- }
-
- 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(
- array(
- 'billing_policy' => array(
- 'period' => 'day',
- 'interval' => 1,
- ),
- )
- )
- );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-07-06 00:00:00' ) );
-
- $this->assertSame( '2026-07-06 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
- }
-
- public function test_reactivate_floors_past_due_at_now_without_a_policy(): void {
- // Selling plan resolves to no row, so there is no cadence to roll by.
- $id = $this->seed_on_hold( '2026-02-01 00:00:00', self::MISSING_PLAN_ID );
-
- $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() );
- }
-
- /**
- * @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() );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-04-15 00:00:00' ) );
-
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
- // No date to arm: the due scan never selects a contract without a next-payment date.
- $this->assertNull( $stored->get_next_payment_gmt() );
- }
-
- public function test_reactivate_fires_the_reactivated_action(): void {
- $id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
- $fired = 0;
- add_action(
- Reactivation::CONTRACT_REACTIVATED_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
-
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
-
- $this->assertSame( 1, $fired );
- }
-
- /**
- * The anchor is cleared after the status write has committed, so a failed delete must
- * not abort the reactivation, which could not be retried on an active contract.
- */
- public function test_a_failed_anchor_clear_does_not_abort_the_reactivation(): void {
- $id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
-
- $fired = 0;
- add_action(
- Reactivation::CONTRACT_REACTIVATED_ACTION,
- static function () use ( &$fired ): void {
- ++$fired;
- }
- );
- $meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
- $break = static function ( string $query ) use ( $meta_table ): string {
- return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
- };
- add_filter( 'query', $break );
-
- try {
- $this->assertTrue( $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) ) );
- } finally {
- remove_filter( 'query', $break );
- }
-
- $stored = $this->reload( $id );
- $this->assertSame( 1, $fired, 'The reactivated action fires.' );
- $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
- }
-
- public function test_a_lost_race_keeps_the_hold_anchor(): void {
- $id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
- $stale = $this->reload( $id );
-
- $concurrent = $this->reload( $id );
- $concurrent->set_status( ContractStatus::CANCELLED );
- $this->contracts->update( $concurrent );
-
- try {
- $this->sut->reactivate( $stale, $this->utc( '2026-06-01 00:00:00' ) );
- $this->fail( 'Expected a DomainException when the conditional write misses.' );
- } catch ( DomainException $e ) {
- $this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
- }
- }
-
- public function test_reactivate_rejects_an_already_active_contract(): void {
- // An active contract past its due date must NOT reach the recompute: rolling its
- // date forward would skip the charge the due scan owes it.
- $id = $this->seed_on_hold( '2026-02-01 00:00:00', $this->make_monthly_plan(), ContractStatus::ACTIVE );
- $contract = $this->reload( $id );
-
- try {
- $this->sut->reactivate( $contract, $this->utc( '2026-04-15 00:00:00' ) );
- $this->fail( 'Expected a DomainException for an already-active contract.' );
- } catch ( DomainException $e ) {
- $row = $this->reload( $id );
- $this->assertSame( ContractStatus::ACTIVE, $row->get_status() );
- $this->assertSame( '2026-02-01 00:00:00', $row->get_next_payment_gmt(), 'The past-due date is untouched, so the due scan still bills it.' );
- }
- }
-
- public function test_reactivate_loses_the_race_to_a_concurrent_transition(): void {
- $id = $this->seed_on_hold( '2099-01-01 00:00:00', self::MISSING_PLAN_ID );
- $contract = $this->reload( $id );
-
- // A concurrent request cancels the contract after our read: the compare-and-set
- // write must miss loudly instead of resurrecting the contract to active.
- $concurrent = $this->reload( $id );
- $concurrent->set_status( ContractStatus::CANCELLED );
- $this->contracts->update( $concurrent );
-
- try {
- $this->sut->reactivate( $contract, $this->utc( '2026-01-01 00:00:00' ) );
- $this->fail( 'Expected a DomainException when the conditional write misses.' );
- } catch ( DomainException $e ) {
- $this->assertSame( ContractStatus::CANCELLED, $this->reload( $id )->get_status(), 'The concurrent cancel is not clobbered.' );
- }
- }
-
- public function test_reactivate_rejects_a_terminal_contract(): void {
- $contract = Contract::create(
- array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => 1,
- 'status' => ContractStatus::CANCELLED,
- 'currency' => 'USD',
- 'selling_plan_id' => $this->make_monthly_plan(),
- 'start_gmt' => '2026-01-01 00:00:00',
- 'billing_total' => '19.99',
- )
- );
- $id = $this->contracts->insert( $contract );
-
- $this->expectException( DomainException::class );
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
- }
-
- public function test_reactivate_rejects_a_pending_cancellation_contract(): void {
- $id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan(), ContractStatus::PENDING_CANCELLATION );
-
- try {
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
- $this->fail( 'Expected a DomainException for a pending-cancellation contract.' );
- } catch ( DomainException $e ) {
- $stored = $this->reload( $id );
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'Nothing was written.' );
- }
- }
-
- public function test_reactivate_rejects_an_unregistered_stored_status(): void {
- global $wpdb;
-
- $id = $this->seed_on_hold( '2099-01-01 00:00:00', $this->make_monthly_plan() );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
-
- try {
- $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) );
- $this->fail( 'Expected a DomainException for an unregistered stored status.' );
- } catch ( DomainException $e ) {
- $stored = $this->reload( $id );
- $this->assertSame( 'legacy-paused', $stored->get_status() );
- $this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt(), 'Nothing was written.' );
- }
- }
-
- /**
- * Reload a contract, asserting it still exists (narrows the nullable read).
- *
- * @param int $id Contract id.
- */
- private function reload( int $id ): Contract {
- $contract = $this->contracts->find( $id );
- $this->assertInstanceOf( Contract::class, $contract );
-
- return $contract;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php
new file mode 100644
index 00000000000..578f3b0aefb
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ContractCapabilitiesTest.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Integration tests for the `manage_subscription_contract` meta capability.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Tests
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Ownership;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities
+ */
+class ContractCapabilitiesTest extends EngineIntegrationTestCase {
+
+ /**
+ * The contract's customer and store managers may read and manage it; other customers and
+ * guests may not.
+ *
+ * @testWith ["read_subscription_contract"]
+ * ["manage_subscription_contract"]
+ *
+ * @param string $capability Capability.
+ */
+ public function test_customer_and_store_managers_hold_the_capability( string $capability ): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+
+ $this->assertTrue( user_can( $customer_id, $capability, $contract ) );
+ $this->assertTrue( user_can( $this->create_user( 'shop_manager' ), $capability, $contract ) );
+ $this->assertFalse( user_can( $this->create_user( 'customer' ), $capability, $contract ) );
+ $this->assertFalse( user_can( 0, $capability, $contract ) );
+ }
+
+ /**
+ * A contract without a customer is for store managers only.
+ */
+ public function test_contract_without_a_customer_is_for_store_managers(): void {
+ $contract = $this->create_contract( null );
+
+ $this->assertFalse( user_can( 0, 'manage_subscription_contract', $contract ) );
+ $this->assertTrue( user_can( $this->create_user( 'administrator' ), 'manage_subscription_contract', $contract ) );
+ }
+
+ /**
+ * Anything but a `ContractView` is refused, even for an administrator.
+ */
+ public function test_requires_a_contract_view(): void {
+ $administrator_id = $this->create_user( 'administrator' );
+ $contract = $this->create_contract( null );
+
+ $this->assertFalse( user_can( $administrator_id, 'manage_subscription_contract', $contract->get_id() ) );
+ $this->assertFalse( user_can( $administrator_id, 'manage_subscription_contract' ) );
+ }
+
+ /**
+ * An extension's `map_meta_cap` filter at the default priority builds on the mapping, whichever
+ * was hooked first.
+ */
+ public function test_extensions_override_the_mapping_at_the_default_priority(): void {
+ $customer_id = $this->create_user( 'customer' );
+ $contract = $this->create_contract( $customer_id );
+ $deny_owner = static function ( $caps, $cap ) {
+ return 'manage_subscription_contract' === $cap && array( 'read' ) === $caps ? array( 'do_not_allow' ) : $caps;
+ };
+
+ remove_filter( 'map_meta_cap', array( ContractCapabilities::class, 'map_meta_cap' ), 0 );
+ add_filter( 'map_meta_cap', $deny_owner, 10, 2 );
+ ContractCapabilities::register_hooks();
+
+ try {
+ $this->assertFalse( user_can( $customer_id, 'manage_subscription_contract', $contract ) );
+ $this->assertTrue( user_can( $customer_id, 'read_subscription_contract', $contract ) );
+ } finally {
+ remove_filter( 'map_meta_cap', $deny_owner, 10 );
+ }
+ }
+
+ /**
+ * Create a contract.
+ *
+ * @param int|null $customer_id Customer id.
+ */
+ private function create_contract( ?int $customer_id ): ContractView {
+ return Contracts::create(
+ array(
+ 'extension_slug' => 'test-extension',
+ 'status' => 'active',
+ 'customer_id' => $customer_id,
+ 'currency' => 'USD',
+ )
+ );
+ }
+
+ /**
+ * Create a user with a role.
+ *
+ * @param string $role Role slug.
+ */
+ private function create_user( string $role ): int {
+ $user_id = self::factory()->user->create( array( 'role' => $role ) );
+ $this->assertIsInt( $user_id );
+
+ return $user_id;
+ }
+}
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 c0e16f372f5..aa961e25cab 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
@@ -21,13 +21,12 @@ use DateTimeImmutable;
use DateTimeZone;
use EngineIntegrationTestCase;
use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
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;
@@ -78,7 +77,13 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
public function test_a_held_contract_is_not_renewed_at_or_after_its_former_date(): void {
$id = $this->sign_up( self::OWNER );
- ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+ Contracts::update(
+ $id,
+ array(
+ 'status' => ContractStatus::ON_HOLD,
+ 'next_payment_gmt' => null,
+ )
+ );
$this->run_batch_at( self::FIRST_DUE );
$this->run_batch_at( '2026-06-01 00:00:00' );
@@ -91,7 +96,14 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
public function test_a_pending_cancellation_contract_is_not_renewed_at_its_end_date(): void {
$id = $this->sign_up( self::OWNER );
- ( new Cancellation( $this->contracts ) )->cancel_at_period_end( $this->reload( $id ) );
+ Contracts::update(
+ $id,
+ array(
+ 'status' => ContractStatus::PENDING_CANCELLATION,
+ 'next_payment_gmt' => null,
+ 'end_gmt' => self::FIRST_DUE,
+ )
+ );
$this->run_batch_at( self::FIRST_DUE );
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 f9990ab4f58..fa156bf7d06 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
@@ -21,7 +21,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
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;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
@@ -1081,84 +1080,6 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertNull( $this->run_scheduled_renewal( 999999 ) );
}
- /**
- * @testdox cancel transitions the contract to cancelled and disarms its next-due moment.
- *
- * Cancellation clears `next_payment_gmt` itself - the flow disarms its own due moment
- * rather than relying on the scan's status predicate.
- */
- public function test_cancel_transitions_the_contract(): 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 );
-
- $this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
-
- $reloaded = ( new ContractRepository() )->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
- $this->assertNull( $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox cancel closes a mid-charge pending cycle.
- */
- public function test_cancel_closes_a_pending_cycle(): void {
- $contract = $this->sign_up_contract( self::GATEWAY );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Append a pending cycle 2 (a charge caught mid-flight).
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
- $pending = Cycle::create(
- array(
- 'contract_id' => $contract_id,
- 'sequence_no' => $previous->get_sequence_no() + 1,
- 'count' => 2,
- 'status' => new CycleStatus( CycleStatus::PENDING ),
- 'starts_at_gmt' => '2026-02-15 00:00:00',
- 'ends_at_gmt' => '2026-03-15 00:00:00',
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- )
- );
- $repo->append_cycle( $pending, $previous );
-
- $this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
-
- // The contract is terminal and the pending cycle is cancelled.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::CANCELLED ) ) );
- }
-
- /**
- * @testdox cancel with only settled cycles leaves them untouched.
- */
- public function test_cancel_leaves_a_settled_cycle_untouched(): void {
- $contract = $this->sign_up_contract( self::GATEWAY );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
-
- // Cycle 1 stays billed (only a pending head is closed by cancel).
- $head = ( new ContractRepository() )->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
-
/**
* @testdox the scheduled scan leaves the cycle processing when the charge is neither paid nor failed.
*