Commit c4bec409775 for woocommerce
commit c4bec4097756db74378c44a0f7ac93be8a16087a
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Tue Oct 6 11:31:29 2026 +0200
Subscriptions engine: registered statuses and an owner-scoped due scan (#69350)
Register subscription statuses and scope the engine due scan to owners
- Add a WordPress-free status registry: engine default contract and cycle statuses plus validated extension registrations; status writes accept only registered slugs, unknown stored statuses round-trip unchanged
- Retire the contract and cycle transition tables; hold, cancel and reactivate enforce the same preconditions explicitly, so REST responses are unchanged
- Have hold and cancellation clear the next-due moment themselves, with hold storing a verified anchor that reactivation resumes from
- Scope the renewal due scan to contracts whose owner is a registered consumer, backed by a new due_owner index (schema 2.4.0)
- Simplify CycleStatus to a validating public constructor and split is_registered() from the slug-format is_valid()
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-primitives-foundation b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-primitives-foundation
new file mode 100644
index 00000000000..536bd564358
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-primitives-foundation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Subscriptions engine package is not released yet; registered statuses and the owner-scoped due scan need no changelog entry.
+
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 8baad69696a..07b30b5cea4 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -21,6 +21,9 @@
* 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
@@ -274,7 +277,7 @@ final class ContractsController extends WP_REST_Controller {
* Run a lifecycle action behind the ownership guard, then return the domain
* summary with the resulting status.
*
- * A `DomainException` (an illegal transition for the contract's current state) maps to
+ * 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.
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index 87598d82570..aca53862b66 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -1,7 +1,8 @@
<?php
/**
* Contract - the stable identity of a subscription and the live source of truth
- * for its current state. Enforces lifecycle transitions through {@see ContractStatus}.
+ * for its current state. Status writes must name a registered status ({@see ContractStatus});
+ * the entity enforces no transition rules between them.
*
* Being the live source of truth (mutable), it holds the live schedule
* (`next_payment_gmt`), the latest snapshot references (`plan_snapshot_id` /
@@ -288,7 +289,7 @@ final class Contract {
$contract = new self( $args );
- if ( ! ContractStatus::is_valid( $contract->status ) ) {
+ if ( ! ContractStatus::is_registered( $contract->status ) ) {
throw new DomainException( sprintf( 'Contract: invalid status "%s".', $contract->status ) );
}
@@ -355,17 +356,23 @@ final class Contract {
}
/**
- * Transition the contract to a new status.
+ * Set the contract status.
*
- * @param string $status Target status.
- * @throws DomainException If the transition is not allowed by ContractStatus.
+ * Any registered status may follow any other: the engine enforces no
+ * transition table (flows own their preconditions). Setting the current
+ * status is a no-op, so a hydrated unregistered status survives it.
+ *
+ * @param string $status Target status; must be registered.
+ * @throws DomainException If `$status` is not a registered contract status.
*/
public function set_status( string $status ): void {
if ( $status === $this->status ) {
return;
}
- ContractStatus::assert_transition_allowed( $this->status, $status );
+ if ( ! ContractStatus::is_registered( $status ) ) {
+ throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
+ }
$this->status = $status;
}
@@ -658,6 +665,24 @@ final class Contract {
return $this->meta;
}
+ /**
+ * Set or remove one meta entry.
+ *
+ * Meta is opaque key/value data; the repository's existing child sync
+ * persists the map on save.
+ *
+ * @param string $key Meta key.
+ * @param string|null $value Meta value, or null to remove the key.
+ */
+ public function set_meta( string $key, ?string $value ): void {
+ if ( null === $value ) {
+ unset( $this->meta[ $key ] );
+ return;
+ }
+
+ $this->meta[ $key ] = $value;
+ }
+
/**
* Serialize the contract row (excluding generated id/timestamps).
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
index d4e10fd4db7..1bb71a5430c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
@@ -1,9 +1,13 @@
<?php
/**
- * ContractStatus - the contract lifecycle state machine.
+ * ContractStatus - the engine's default contract status slugs plus read helpers
+ * over the {@see StatusRegistry}.
*
- * Owns the set of valid statuses and the allowed transitions between them.
- * Status transitions are validated here and applied by the {@see Contract} entity.
+ * Contract status is opaque engine data. The constants name the engine defaults:
+ * the defaults are shared slugs and carry no engine meaning; the engine enforces
+ * no transitions. Extensions may register more through
+ * {@see StatusRegistry::register()}. The
+ * {@see Contract} entity refuses to write a status that is not registered.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
*/
@@ -12,8 +16,6 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
-use DomainException;
-
defined( 'ABSPATH' ) || exit;
/**
@@ -28,11 +30,11 @@ final class ContractStatus {
public const EXPIRED = 'expired';
/**
- * All known statuses.
+ * The engine's default contract statuses (the registry seed).
*
* @return array<int, string>
*/
- public static function all(): array {
+ public static function get_defaults(): array {
return array(
self::ACTIVE,
self::ON_HOLD,
@@ -43,87 +45,32 @@ final class ContractStatus {
}
/**
- * Whether `$status` is a known status.
+ * Every registered contract status: the engine defaults, then extension
+ * registrations.
*
- * @param string $status Status to check.
+ * @return array<int, string>
*/
- public static function is_valid( string $status ): bool {
- return in_array( $status, self::all(), true );
+ public static function get_all(): array {
+ return StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT );
}
/**
- * Whether `$status` is terminal (no transitions out).
+ * Whether `$status` is a registered contract status (an engine default or an extension
+ * registration). Write paths accept only registered statuses.
*
* @param string $status Status to check.
*/
- public static function is_terminal( string $status ): bool {
- return self::is_valid( $status ) && array() === self::transitions()[ $status ];
- }
-
- /**
- * Whether a contract may move from `$from` to `$to`.
- *
- * Unknown source or target statuses are reported as not allowed, so a row
- * that has drifted into an unrecognized state cannot be transitioned out of
- * a value we do not know how to reason about. Same-status calls
- * (`active` -> `active`) report false here; {@see Contract::set_status()}
- * short-circuits no-ops before consulting this table so they do not surface
- * as exceptions to callers.
- *
- * @param string $from Current status.
- * @param string $to Target status.
- */
- public static function is_transition_allowed( string $from, string $to ): bool {
- if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
- return false;
- }
-
- return in_array( $to, self::transitions()[ $from ], true );
+ public static function is_registered( string $status ): bool {
+ return StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, $status );
}
/**
- * Whether a contract may move from `$from` to `$to`.
+ * Whether `$status` is a well-formed status slug (lowercase letters and digits in
+ * words joined by single hyphens, at most 20 characters), registered or not.
*
- * Alias of {@see self::is_transition_allowed()}.
- *
- * @param string $from Current status.
- * @param string $to Target status.
- */
- public static function can_transition( string $from, string $to ): bool {
- return self::is_transition_allowed( $from, $to );
- }
-
- /**
- * Throw if `$from` -> `$to` is not an allowed transition.
- *
- * The canonical enforcement entry point: every status change flows through
- * here before the new status is applied, which makes "no nonsense states" a
- * structural guarantee rather than a code-review aspiration.
- *
- * @param string $from Current status.
- * @param string $to Target status.
- * @throws DomainException When the transition is rejected by {@see self::is_transition_allowed()}.
- */
- public static function assert_transition_allowed( string $from, string $to ): void {
- if ( ! self::is_transition_allowed( $from, $to ) ) {
- throw new DomainException(
- sprintf( 'ContractStatus: illegal status transition from "%s" to "%s".', $from, $to )
- );
- }
- }
-
- /**
- * Allowed transitions: current status => list of reachable statuses.
- *
- * @return array<string, array<int, string>>
+ * @param string $status Status to check.
*/
- private static function transitions(): array {
- return array(
- self::ACTIVE => array( self::ON_HOLD, self::PENDING_CANCELLATION, self::CANCELLED, self::EXPIRED ),
- self::ON_HOLD => array( self::ACTIVE, self::PENDING_CANCELLATION, self::CANCELLED ),
- self::PENDING_CANCELLATION => array( self::ACTIVE, self::CANCELLED ),
- self::CANCELLED => array(),
- self::EXPIRED => array(),
- );
+ public static function is_valid( string $status ): bool {
+ return StatusRegistry::is_valid_slug( $status );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index 6abe8d370b9..b43a0ef51ce 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -228,6 +228,7 @@ final class Cycle {
self::assert_valid_kind( $cycle->kind );
self::assert_valid_sequence_no( $cycle->sequence_no );
+ self::assert_registered_status( $cycle->status );
return $cycle;
}
@@ -235,8 +236,13 @@ final class Cycle {
/**
* Hydrate from a stored row.
*
+ * A well-formed stored status is kept verbatim, registered or not (registration is
+ * checked only where a status is written), so a value written by a since-deactivated
+ * extension round-trips unchanged.
+ *
* @param array<string, mixed> $row Cycle row.
- * @throws DomainException If the stored status, kind, or sequence_no is invalid.
+ * @throws DomainException If the stored kind or sequence_no is invalid, or the stored
+ * status is not a well-formed status slug.
*/
public static function from_storage( array $row ): self {
$kind = ScalarCoercion::coerce_string( $row['kind'] ?? null, self::KIND_BILLING );
@@ -327,17 +333,24 @@ final class Cycle {
}
/**
- * Transition the cycle to a new status.
+ * Set the cycle status.
+ *
+ * Any status may follow any other: the engine enforces no transition table. A
+ * changed status must be registered (an unregistered value, such as one built
+ * through {@see new CycleStatus()}, is rejected); setting the current status
+ * is a no-op, so a hydrated unregistered status can be saved unchanged.
*
* @param CycleStatus $status Target status.
- * @throws DomainException If the transition is not allowed by CycleStatus.
+ * @throws DomainException If the status changes to an unregistered value.
*/
public function set_status( CycleStatus $status ): void {
if ( $this->status->equals( $status ) ) {
return;
}
- $this->status = $this->status->transition_to( $status );
+ self::assert_registered_status( $status );
+
+ $this->status = $status;
}
/**
@@ -591,14 +604,31 @@ final class Cycle {
}
}
+ /**
+ * Reject a status value that is not registered. Write paths only
+ * ({@see self::create()}, {@see self::set_status()}); storage hydration keeps an
+ * unregistered stored value verbatim.
+ *
+ * @param CycleStatus $status Status to check.
+ * @throws DomainException If the status is not registered.
+ */
+ private static function assert_registered_status( CycleStatus $status ): void {
+ if ( ! CycleStatus::is_registered( $status->get_value() ) ) {
+ throw new DomainException(
+ sprintf( 'Cycle: status "%s" is not registered.', $status->get_value() )
+ );
+ }
+ }
+
/**
* Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
- * through; null defaults to `pending`; a string is validated via
- * {@see CycleStatus::from()}.
+ * through; null defaults to `pending`; a string is wrapped (slug format checked).
+ * Registration is checked by {@see self::create()} and {@see self::set_status()},
+ * not here, so storage hydration keeps an unregistered stored value.
*
* @param mixed $status Raw status value (a CycleStatus, null, or a status string).
* @return CycleStatus
- * @throws DomainException If a status string is not a known status.
+ * @throws DomainException If a status string is not a well-formed status slug.
*/
private static function coerce_status( $status ): CycleStatus {
if ( $status instanceof CycleStatus ) {
@@ -606,10 +636,10 @@ final class Cycle {
}
if ( null === $status ) {
- return CycleStatus::pending();
+ return new CycleStatus( CycleStatus::PENDING );
}
- return CycleStatus::from( ScalarCoercion::coerce_string( $status ) );
+ return new CycleStatus( ScalarCoercion::coerce_string( $status ) );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
index 7a29582015f..bb0852845e3 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -1,17 +1,13 @@
<?php
/**
- * CycleStatus - the cycle lifecycle state machine, as an immutable value object.
- * Owns the valid statuses and allowed transitions so an invalid state cannot be
- * represented. Mirrors {@see ContractStatus}.
+ * CycleStatus - a cycle status as an immutable value object, plus read helpers
+ * over the {@see StatusRegistry}. Mirrors {@see ContractStatus}.
*
- * Lifecycle: a cycle is born `pending`; a charge submitted to a gateway that has not
- * yet returned a terminal outcome (an async method awaiting confirmation) is
- * `processing`; it settles to `billed` (terminal) or `failed`. A `failed` cycle can be
- * retried back to `pending` (an admin-triggered re-attempt), and any non-settled cycle
- * can be `cancelled` (terminal). The state is shared with the shipping chain, so
- * `processing` names "submitted, awaiting a terminal outcome" without payment-specific wording.
- * Instance methods serve the entity; the static string helpers operate on raw strings at
- * the storage boundary.
+ * Cycle status is opaque engine data. The constants name the engine defaults:
+ * the defaults are shared slugs and carry no engine meaning; the engine enforces
+ * no transitions. Extensions may register more through
+ * {@see StatusRegistry::register()}. The slugs are shared with the shipping chain,
+ * so `processing` avoids payment-specific wording.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
*/
@@ -27,8 +23,11 @@ defined( 'ABSPATH' ) || exit;
/**
* CycleStatus value object.
*
- * Immutable. Construct via a named factory ({@see self::pending()} etc.) or
- * {@see self::from()}.
+ * Immutable: `new CycleStatus( CycleStatus::PENDING )`. The constructor checks the slug
+ * format only, so a stored status a since-deactivated extension wrote still loads;
+ * whether a status is registered is checked where a cycle status is written
+ * ({@see Cycle::create()}, {@see Cycle::set_status()}, the repository's status
+ * compare-and-set).
*/
final class CycleStatus {
@@ -46,63 +45,19 @@ final class CycleStatus {
private $value;
/**
- * Use a named factory ({@see self::pending()} etc.) or {@see self::from()}.
+ * Wrap a status slug.
*
- * @param string $value A known status string.
+ * @param string $value Status slug.
+ * @throws DomainException If `$value` is not a well-formed status slug.
*/
- private function __construct( string $value ) {
- $this->value = $value;
- }
-
- /**
- * Build a status value from a known status string.
- *
- * @param string $value Status string.
- * @throws DomainException If `$value` is not a known status.
- */
- public static function from( string $value ): self {
+ public function __construct( string $value ) {
if ( ! self::is_valid( $value ) ) {
throw new DomainException(
- sprintf( 'CycleStatus: "%s" is not a known status.', $value )
+ sprintf( 'CycleStatus: "%s" is not a valid status slug.', $value )
);
}
- return new self( $value );
- }
-
- /**
- * The `pending` status (charge in flight; values locked at creation).
- */
- public static function pending(): self {
- return new self( self::PENDING );
- }
-
- /**
- * The `processing` status (charge submitted, awaiting a terminal outcome; non-terminal).
- */
- public static function processing(): self {
- return new self( self::PROCESSING );
- }
-
- /**
- * The `billed` status (settled after a successful charge; terminal).
- */
- public static function billed(): self {
- return new self( self::BILLED );
- }
-
- /**
- * The `failed` status (charge declined; non-terminal).
- */
- public static function failed(): self {
- return new self( self::FAILED );
- }
-
- /**
- * The `cancelled` status (closed; terminal).
- */
- public static function cancelled(): self {
- return new self( self::CANCELLED );
+ $this->value = $value;
}
/**
@@ -122,32 +77,11 @@ final class CycleStatus {
}
/**
- * Whether this status may move to `$target`.
- *
- * @param CycleStatus $target Target status.
- */
- public function can_transition_to( CycleStatus $target ): bool {
- return self::is_transition_allowed( $this->value, $target->value );
- }
-
- /**
- * Move to `$target`, returning the new status value.
- *
- * @param CycleStatus $target Target status.
- * @throws DomainException If the transition is not allowed.
- */
- public function transition_to( CycleStatus $target ): self {
- self::assert_transition_allowed( $this->value, $target->value );
-
- return $target;
- }
-
- /**
- * All known statuses, in lifecycle order.
+ * The engine's default cycle statuses (the registry seed).
*
* @return array<int, string>
*/
- public static function all(): array {
+ public static function get_defaults(): array {
return array(
self::PENDING,
self::PROCESSING,
@@ -158,79 +92,45 @@ final class CycleStatus {
}
/**
- * Whether `$status` is a known status.
+ * Every registered cycle status: the engine defaults, then extension
+ * registrations.
*
- * @param string $status Status to check.
+ * @return array<int, string>
*/
- public static function is_valid( string $status ): bool {
- return in_array( $status, self::all(), true );
+ public static function get_all(): array {
+ return StatusRegistry::get_all( StatusRegistry::KIND_CYCLE );
}
/**
- * Whether `$status` is terminal (no transitions out).
+ * Whether `$status` is a registered cycle status (an engine default or an extension
+ * registration). Write paths accept only registered statuses.
*
* @param string $status Status to check.
*/
- public static function is_terminal( string $status ): bool {
- return self::is_valid( $status ) && array() === self::transitions()[ $status ];
+ public static function is_registered( string $status ): bool {
+ return StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, $status );
}
/**
- * Whether a cycle may move from `$from` to `$to`. Unknown statuses report false.
- * Same-status calls also report false; {@see Cycle::set_status()} short-circuits
- * no-ops before consulting this table.
+ * Whether `$status` is a well-formed status slug (lowercase letters and digits in
+ * words joined by single hyphens, at most 20 characters), registered or not.
*
- * @param string $from Current status.
- * @param string $to Target status.
- */
- public static function is_transition_allowed( string $from, string $to ): bool {
- if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
- return false;
- }
-
- return in_array( $to, self::transitions()[ $from ], true );
- }
-
- /**
- * Whether a cycle may move from `$from` to `$to`.
- *
- * Alias of {@see self::is_transition_allowed()}.
- *
- * @param string $from Current status.
- * @param string $to Target status.
+ * @param string $status Status to check.
*/
- public static function can_transition( string $from, string $to ): bool {
- return self::is_transition_allowed( $from, $to );
+ public static function is_valid( string $status ): bool {
+ return StatusRegistry::is_valid_slug( $status );
}
/**
- * Throw if `$from` -> `$to` is not an allowed transition. The canonical
- * enforcement entry point every status change flows through.
+ * Whether `$status` is `billed` or `cancelled`.
*
- * @param string $from Current status.
- * @param string $to Target status.
- * @throws DomainException When the transition is rejected by {@see self::is_transition_allowed()}.
- */
- public static function assert_transition_allowed( string $from, string $to ): void {
- if ( ! self::is_transition_allowed( $from, $to ) ) {
- throw new DomainException(
- sprintf( 'CycleStatus: illegal status transition from "%s" to "%s".', $from, $to )
- );
- }
- }
-
- /**
- * Allowed transitions: current status => list of reachable statuses.
+ * Interim: moves out of the engine with the renewal flow (only the repository's
+ * snapshot skip in `hydrate_cycle()` reads this). Unknown and
+ * extension-registered statuses report false.
*
- * @return array<string, array<int, string>>
+ * @param string $status Status to check.
*/
- private static function transitions(): array {
- return array(
- self::PENDING => array( self::PROCESSING, self::BILLED, self::FAILED, self::CANCELLED ),
- self::PROCESSING => array( self::BILLED, self::FAILED, self::CANCELLED ),
- self::BILLED => array(),
- self::FAILED => array( self::PENDING, self::CANCELLED ),
- self::CANCELLED => array(),
- );
+ public static function is_terminal( string $status ): bool {
+ return in_array( $status, array( self::BILLED, self::CANCELLED ), true );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
new file mode 100644
index 00000000000..6ef258dac51
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/StatusRegistry.php
@@ -0,0 +1,155 @@
+<?php
+/**
+ * StatusRegistry - the set of registered contract and cycle statuses.
+ *
+ * Statuses are opaque engine data: the engine ships a default set per kind
+ * ({@see ContractStatus::get_defaults()}, {@see CycleStatus::get_defaults()}) and
+ * extensions may register more. The registry holds slugs only - no labels, no
+ * transitions, no meaning - and is global (not per owner). Registration is the
+ * write-path allowlist: entity setters and the cycle status write refuse a slug
+ * that is not registered. Stored values outside the registry (for example one
+ * written by a since-deactivated extension) still hydrate and round-trip
+ * unchanged; the registry never gates reads.
+ *
+ * Core zone: WordPress-free by design. No WP/Woo symbols, no time functions.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use InvalidArgumentException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Static registry of status slugs per kind.
+ */
+final class StatusRegistry {
+
+ /**
+ * Contract status kind.
+ */
+ public const KIND_CONTRACT = 'contract';
+
+ /**
+ * Cycle status kind.
+ */
+ public const KIND_CYCLE = 'cycle';
+
+ /**
+ * Longest accepted slug (the status columns are `varchar(20)`).
+ */
+ private const MAX_LENGTH = 20;
+
+ /**
+ * Slug format: lowercase alphanumeric words joined by single hyphens. Anchored with `\z`
+ * (not `$`, which also matches before a trailing newline).
+ */
+ private const SLUG_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*\z/';
+
+ /**
+ * Extension registrations, keyed by kind => list of slugs in registration
+ * order. The engine defaults are intrinsic and never stored here.
+ *
+ * Static (not instance state) because the public registration API is itself
+ * static - every consumer reaches the registry by class name.
+ *
+ * @var array<string, array<int, string>>
+ */
+ private static $registered = array();
+
+ /**
+ * Register an extension status slug for `$kind`.
+ *
+ * Idempotent: registering a default or an already-registered slug changes
+ * nothing.
+ *
+ * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+ * @param string $slug Status slug; must satisfy {@see self::is_valid_slug()}.
+ * @throws InvalidArgumentException When the kind is unknown or the slug is malformed.
+ */
+ public static function register( string $kind, string $slug ): void {
+ self::assert_known_kind( $kind );
+
+ if ( ! self::is_valid_slug( $slug ) ) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'StatusRegistry: "%s" is not a valid status slug (lowercase letters, digits and single hyphens, at most %d characters).',
+ $slug,
+ self::MAX_LENGTH
+ )
+ );
+ }
+
+ if ( in_array( $slug, self::get_all( $kind ), true ) ) {
+ return;
+ }
+
+ self::$registered[ $kind ][] = $slug;
+ }
+
+ /**
+ * Whether `$slug` is a registered status (a default or an extension
+ * registration) for `$kind`.
+ *
+ * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+ * @param string $slug Status slug.
+ * @throws InvalidArgumentException When the kind is unknown.
+ */
+ public static function is_registered( string $kind, string $slug ): bool {
+ return in_array( $slug, self::get_all( $kind ), true );
+ }
+
+ /**
+ * Every registered status for `$kind`: the engine defaults first, then
+ * extension registrations in registration order.
+ *
+ * @param string $kind One of {@see self::KIND_CONTRACT} or {@see self::KIND_CYCLE}.
+ * @return array<int, string>
+ * @throws InvalidArgumentException When the kind is unknown.
+ */
+ public static function get_all( string $kind ): array {
+ self::assert_known_kind( $kind );
+
+ $defaults = self::KIND_CONTRACT === $kind ? ContractStatus::get_defaults() : CycleStatus::get_defaults();
+
+ return array_merge( $defaults, self::$registered[ $kind ] ?? array() );
+ }
+
+ /**
+ * Whether `$slug` satisfies the status slug format: lowercase letters and
+ * digits in words joined by single hyphens, at most 20 characters.
+ *
+ * @param string $slug Candidate slug.
+ */
+ public static function is_valid_slug( string $slug ): bool {
+ return strlen( $slug ) <= self::MAX_LENGTH && 1 === preg_match( self::SLUG_PATTERN, $slug );
+ }
+
+ /**
+ * Clear every extension registration. The engine defaults remain.
+ *
+ * @internal Public only so test setUp/tearDown can isolate per-test state.
+ * Not part of the consumer API.
+ */
+ public static function reset(): void {
+ self::$registered = array();
+ }
+
+ /**
+ * Throw unless `$kind` is a known status kind.
+ *
+ * @param string $kind Kind to check.
+ * @throws InvalidArgumentException When the kind is unknown.
+ */
+ private static function assert_known_kind( string $kind ): void {
+ if ( self::KIND_CONTRACT !== $kind && self::KIND_CYCLE !== $kind ) {
+ throw new InvalidArgumentException(
+ sprintf( 'StatusRegistry: unknown status kind "%s".', $kind )
+ );
+ }
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
index 2c66e7ca55b..d45da773209 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
@@ -111,7 +111,7 @@ final class RenewalCalculator {
'contract_id' => $values['contract_id'] ?? null,
'sequence_no' => $values['sequence_no'] ?? null,
'count' => $values['count'] ?? null,
- 'status' => CycleStatus::pending(),
+ 'status' => new CycleStatus( CycleStatus::PENDING ),
'starts_at_gmt' => $start->format( 'Y-m-d H:i:s' ),
'ends_at_gmt' => $end->format( 'Y-m-d H:i:s' ),
'expected_total' => $values['expected_total'] ?? null,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
index 6e03a723b5c..d819f4ed291 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -152,7 +152,7 @@ final class ContractFactory {
'contract_id' => 0,
'sequence_no' => 1,
'count' => 1,
- 'status' => CycleStatus::billed(),
+ 'status' => new CycleStatus( CycleStatus::BILLED ),
'order_id' => $order->get_id(),
'extension_slug' => $plan->get_extension_slug(),
'starts_at_gmt' => $starts_at,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
index 0f227678842..6e01b577de1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -7,8 +7,14 @@
* 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). Lives under `Integration\Contracts` so contract lifecycle stays
- * separate from the renewal money-path.
+ * 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
*/
@@ -57,14 +63,16 @@ final class Cancellation {
}
/**
- * Cancel `$contract`: transition to cancelled and close any mid-charge cycle.
+ * Cancel `$contract`: move it to cancelled, disarm its next-due moment, and close any
+ * mid-charge cycle.
*
- * Status moves through the Core state machine ({@see Contract::set_status()}), which raises
- * a `DomainException` on an illegal transition. 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. The due scan only selects active contracts,
- * so a cancelled contract simply stops being picked up - there is no per-contract schedule
- * to clear.
+ * Only an active, on-hold or pending-cancellation contract can be cancelled; 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.
@@ -77,8 +85,17 @@ final class Cancellation {
throw new RuntimeException( 'Cancellation::cancel(): cannot cancel a contract that has no id.' );
}
- $previous = $contract->get_status();
- $contract->set_status( ContractStatus::CANCELLED );
+ $previous = $contract->get_status();
+ $cancelable = array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED );
+ if ( ! in_array( $previous, $cancelable, true ) ) {
+ throw new \DomainException( 'Cancellation::cancel(): only an 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 );
+ $contract->set_meta( Hold::ANCHOR_META_KEY, 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
@@ -90,8 +107,8 @@ final class Cancellation {
// 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( CycleStatus::pending() ) ) {
- $current->set_status( CycleStatus::cancelled() );
+ if ( null !== $current && $current->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) ) {
+ $current->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
$this->contracts->update_cycle( $current );
}
@@ -106,26 +123,24 @@ final class Cancellation {
}
/**
- * Wind `$contract` down at the end of the current period: transition to
- * pending-cancellation and stamp the end date.
- *
- * Status moves through the Core state machine ({@see Contract::set_status()}), which
- * raises a `DomainException` on an illegal transition. The contract keeps serving
- * until the current period ends, so the next-payment moment is recorded as the
- * contract `end_gmt` (when not already set) for a first-class "cancels on" date, and
- * the next-payment date is deliberately LEFT in place so the contract lapses at the
- * date rather than being torn down now.
+ * 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.
*
- * The due scan already refuses to charge a non-active contract ({@see RenewalEngine::process()}
- * skips it with no order), so no renewal fires while it winds down.
+ * 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 (ACTIVE has lapsed) when its date
- * arrives - moving it to CANCELLED/EXPIRED at period end - is a follow-up slice. The
- * current dispatcher only skips a non-active contract; it does not yet transition it
- * terminal at the date, so a wound-down contract stays PENDING_CANCELLATION until a
- * later terminate-at-date pass lands. No charge occurs in the meantime.
+ * 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.
+ * @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.
@@ -137,13 +152,25 @@ final class Cancellation {
}
$previous = $contract->get_status();
- $contract->set_status( ContractStatus::PENDING_CANCELLATION );
+ 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.' );
+ }
- // The end of the current period is the next-payment moment: the contract is
- // honoured up to (not through) it. Record it as the contract end when not already
- // set, so reads have a first-class "cancels on" date.
- if ( null === $contract->get_end_gmt() && null !== $contract->get_next_payment_gmt() ) {
- $contract->set_end_gmt( $contract->get_next_payment_gmt() );
+ 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( $contract );
+ }
+ if ( null === $contract->get_end_gmt() && null !== $period_end ) {
+ $contract->set_end_gmt( $period_end );
+ }
+
+ $contract->set_meta( Hold::ANCHOR_META_KEY, null );
+ $contract->set_next_payment_gmt( null );
}
// Compare-and-set on the status read above: a concurrent transition makes this
@@ -152,8 +179,6 @@ final class Cancellation {
throw new \DomainException( 'Cancellation::cancel_at_period_end(): the contract state changed concurrently; nothing was written.' );
}
- // Intentionally leave the next-payment date in place: the contract lapses at the date (see the TODO above).
-
/**
* Fires after a contract is set to wind down at the end of the current period.
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
index f9f83dd87ba..6a9533929c2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
@@ -3,12 +3,16 @@
* Hold - put an active subscription contract on hold (suspend billing).
*
* A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: transition the contract ACTIVE -> ON_HOLD through the
- * Core state machine and announce it. No charge fires while held because the batch due
- * scan only bills active contracts, so there is no per-contract schedule to clear. The
- * contract keeps its `next_payment_gmt` so the held duration is recoverable on
- * {@see Reactivation}. Lives under `Integration\Contracts` so contract lifecycle stays
- * separate from the renewal money-path.
+ * 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
*/
@@ -17,6 +21,8 @@ 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;
@@ -34,6 +40,15 @@ final class Hold {
*/
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.
*
@@ -51,17 +66,15 @@ final class Hold {
}
/**
- * Hold `$contract`: transition it to on-hold.
+ * Hold `$contract`: move it to on-hold and disarm its next-due moment.
*
- * Status moves through the Core state machine ({@see Contract::set_status()}), which
- * raises a `DomainException` on an illegal transition (e.g. holding a terminal
- * contract). The current cycle is immutable and is NOT touched; only the live
- * contract status moves. No charge fires while held because the batch due scan only
- * bills active contracts - there is no per-contract schedule to clear. The
- * `next_payment_gmt` is preserved so {@see Reactivation} can recompute the schedule
- * forward.
+ * 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.
+ * @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.
@@ -73,11 +86,21 @@ final class Hold {
}
$previous = $contract->get_status();
- $contract->set_status( ContractStatus::ON_HOLD );
+ 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.
+ // 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.' );
}
@@ -91,4 +114,65 @@ final class Hold {
return true;
}
+
+ /**
+ * Store the next-due moment as the hold anchor while the contract is still active,
+ * before the hold disarms it.
+ *
+ * The repository writes the row and its meta as separate statements (no
+ * transaction), so the anchor 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 (null removes the key). 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 \DomainException If the contract stopped being active concurrently.
+ * @throws RuntimeException If the anchor could not be stored.
+ */
+ private function persist_anchor( Contract $contract ): void {
+ $next_payment_gmt = $contract->get_next_payment_gmt();
+ $contract->set_meta( self::ANCHOR_META_KEY, $next_payment_gmt );
+
+ if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE ) ) {
+ throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
+ }
+
+ $stored = $this->contracts->find( (int) $contract->get_id() );
+ $anchor = null === $stored ? null : ( $stored->get_meta()[ self::ANCHOR_META_KEY ] ?? null );
+ if ( $anchor !== $next_payment_gmt ) {
+ throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.' );
+ }
+ }
+
+ /**
+ * The hold anchor stored on `$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 Contract $contract Contract to read.
+ */
+ public static function read_anchor( Contract $contract ): ?string {
+ $anchor = $contract->get_meta()[ self::ANCHOR_META_KEY ] ?? '';
+ if ( '' === $anchor ) {
+ return null;
+ }
+
+ $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.', (int) $contract->get_id() ),
+ array(
+ 'source' => 'woocommerce-subscriptions-engine',
+ 'contract_id' => $contract->get_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
index 8ff46ddcd41..e7629263066 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -3,11 +3,15 @@
* Reactivation - resume a held subscription contract (resume billing).
*
* A focused contract-management operation (deliberately not a catch-all manager),
- * mirroring {@see Cancellation}: transition the contract ON_HOLD -> ACTIVE through the
- * Core state machine, recompute the next-payment date forward, and announce it. Setting
- * the contract active with a forward next-payment date is the re-arm: the batch due scan
- * picks it up at the date. Lives under `Integration\Contracts` so contract lifecycle
- * stays separate from the renewal money-path.
+ * 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
@@ -81,16 +85,18 @@ final class Reactivation {
}
/**
- * Reactivate `$contract`: transition to active, recompute the next-payment date
- * forward, and persist.
+ * Reactivate `$contract`: move it to active, re-arm the next-payment date forward,
+ * and persist.
*
- * Status moves through the Core state machine ({@see Contract::set_status()}), which
- * raises a `DomainException` on an illegal transition (e.g. reactivating a terminal
- * contract). The next date is recomputed through the single seam
+ * 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. Setting
- * the contract active with that forward date is the re-arm - the batch due scan picks
- * it up when the date arrives; a null next-payment simply leaves it unscheduled.
+ * 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.
@@ -104,11 +110,9 @@ final class Reactivation {
throw new RuntimeException( 'Reactivation::reactivate(): cannot reactivate a contract that has no id.' );
}
- // Only a held contract reactivates. The state machine rejects terminal states on
- // its own, but an already-ACTIVE contract would silently no-op through it and
- // still reach the recompute below - and rolling a past-due active contract's
- // next-payment date forward would skip the charge the due scan owes it. Reject
- // it explicitly before any date math.
+ // 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.' );
}
@@ -116,8 +120,13 @@ final class Reactivation {
// 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( $contract );
+
+ $contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $anchor, $now, $this->billing_policy( $contract ) ) );
+ $contract->set_meta( Hold::ANCHOR_META_KEY, null );
$contract->set_status( ContractStatus::ACTIVE );
- $contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $now, $this->billing_policy( $contract ) ) );
// Compare-and-set on the ON_HOLD status read above: a concurrent transition
// (another request, the renewal engine) makes this write miss loudly rather
@@ -127,7 +136,8 @@ final class Reactivation {
}
/**
- * Fires after a held contract is reactivated and its renewal re-armed.
+ * 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.
*
* @param Contract $contract The reactivated contract.
*/
@@ -146,7 +156,7 @@ final class Reactivation {
* Default = "Model 1" (suspend without mutating the immutable current cycle;
* reactivate recomputes the next date FORWARD, with no catch-up / back-charge):
*
- * - A future stored date is kept as-is - resuming before the date arrives changes
+ * - 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
@@ -154,24 +164,24 @@ final class Reactivation {
* 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 scheduled next payment stays unscheduled.
+ * - 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.
+ * @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, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
- $next_payment_gmt = $contract->get_next_payment_gmt();
- if ( null === $next_payment_gmt ) {
+ private function recompute_next_payment( Contract $contract, ?string $anchor, DateTimeImmutable $now, ?BillingPolicy $policy ): ?string {
+ if ( null === $anchor ) {
return null;
}
- $next = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );
+ $next = new DateTimeImmutable( $anchor, new DateTimeZone( 'UTC' ) );
// Still in the future: resuming before the date arrives keeps the schedule.
if ( $next > $now ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
index 3be141144f3..4613a47c753 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
@@ -3,8 +3,12 @@
* RenewalDispatcher - the autonomous batch renewal scanner.
*
* One recurring Action Scheduler job drives every scheduled renewal: each tick runs
- * {@see self::run_batch()} over the cycle-aware due-index. The class owns the recurring
- * action's registration, scheduling, and hook callback.
+ * {@see self::run_batch()} over the cycle-aware due-index. Due-ness is owner-scoped: a
+ * contract is due when its next-due moment has passed and its owner is a registered
+ * consumer, so an empty consumer registry selects nothing (the scan's interim renewal-flow
+ * predicates, active status among them, are described on
+ * {@see ContractRepository::find_due()}). The class owns the recurring action's
+ * registration, scheduling, and hook callback.
*
* The create-as-claim ({@see RenewalEngine}) plus the cycle crash-recovery lease keep
* overlap correct, so the scan needs no claim of its own: a contract picked up twice
@@ -127,9 +131,10 @@ final class RenewalDispatcher {
* Enqueue the recurring scan action when one is not already scheduled.
*
* Call once Action Scheduler is available (Bootstrap runs it on `action_scheduler_init`,
- * the moment AS declares its `as_*` functions ready). Gated on the consumer registry: a store with no consumer extension runs no
- * renewals, so it carries no recurring scan action either - one already scheduled is
- * removed on the first gated boot after the last consumer deactivates. To avoid an Action Scheduler
+ * the moment AS declares its `as_*` functions ready). Skipped while the consumer registry
+ * is empty: the due scan is scoped to registered owners, so a store with no consumer
+ * extension has nothing due and carries no recurring scan action either (an optimization -
+ * one already scheduled is removed on the first boot after the last consumer deactivates). To avoid an Action Scheduler
* store query on every request, a positive result is cached in an autoloaded option and
* re-verified only once per re-check window - bounded staleness that self-heals if the
* action is ever cleared. Within a re-verify it still guards with the `is_scheduled()`
@@ -200,12 +205,13 @@ final class RenewalDispatcher {
}
/**
- * Run one scan tick over up to `$limit` due contracts: gate, then drive every due renewal.
+ * Run one scan tick over up to `$limit` due contracts.
*
- * The processing gate comes first - with no registered consumer the engine charges
- * nothing and the run returns immediately. Otherwise the cycle-aware scan returns the
- * actionable contracts due at `$now`; each is run through read-only selection and, when a
- * cycle is due, billed via {@see RenewalEngine::process()}. A pre-flight impossibility
+ * The scan is owner-scoped: the registered consumers are read once and passed to
+ * {@see ContractRepository::find_due()}, so only their contracts are selected; with no
+ * consumer registered the tick logs that and charges nothing. The scan returns the actionable
+ * contracts due at `$now`; each is run through read-only selection and, when a cycle is due,
+ * billed via {@see RenewalEngine::process()}. A pre-flight impossibility
* ({@see RenewalNotProcessable}) parks the contract; any other throw is logged - so one bad
* contract cannot stall the batch. A backlog larger than `$limit` drains over successive ticks.
*
@@ -220,7 +226,10 @@ final class RenewalDispatcher {
return 0;
}
- if ( ConsumerRegistry::is_empty() ) {
+ // Read the owner set once per tick; with nobody registered there is nothing to scan,
+ // and saying so in the log beats a silent empty batch.
+ $owners = ConsumerRegistry::all();
+ if ( array() === $owners ) {
wc_get_logger()->info(
'RenewalDispatcher::run(): no consumer extension is registered - skipping the renewal scan (charging nothing).',
array( 'source' => self::LOG_SOURCE )
@@ -229,7 +238,7 @@ final class RenewalDispatcher {
}
$now = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
- $candidates = $this->contracts->find_due( $now, $limit );
+ $candidates = $this->contracts->find_due( $now, $limit, $owners );
$billed = 0;
$skipped = 0;
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
index 4b0a78b86a2..fe021b7e95d 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -208,8 +208,10 @@ final class RenewalEngine {
*
* The structural invariants it does enforce keep the money-path safe whatever the caller:
* it skips (logging, never throwing - a scheduled action would retry a permanent condition
- * forever) when the contract is gone, gateway-scheduled, or inactive, and refuses a cycle
- * that is neither the head nor its immediate successor (no billing a gap). The claim is the
+ * forever) when the contract is gone, gateway-scheduled, or not active, and refuses a cycle
+ * that is neither the head nor its immediate successor (no billing a gap). The non-active
+ * skip never parks: the engine does not clear the next-due moment of a contract whose
+ * status it did not set. The claim is the
* concurrency gate: appending the successor collides on `UNIQUE(contract_id, kind, count)`
* and the head is reclaimed only through the lease compare-and-set, so a cycle is charged at
* most once even under overlapping runs. Order reconciliation follows the claim, so the
@@ -217,8 +219,9 @@ final class RenewalEngine {
*
* Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no chain, an
* unresolvable plan, a non-adjacent count, a gateway that cannot charge renewals) so the
- * caller can park; returns null for an idempotent no-op (a live claim, an already-settled
- * cycle, an unbuildable order).
+ * scheduled caller can park and a manual caller can return null; returns null for an
+ * idempotent no-op (a non-active contract, a live claim, an already-settled cycle, an
+ * unbuildable order).
*
* @param RenewalIntent $intent The contract and cycle count to bill.
* @param DateTimeImmutable $now The processing moment (the lease clock for a claim).
@@ -252,6 +255,10 @@ final class RenewalEngine {
return null;
}
+ // Interim: moves out of the engine with the renewal flow.
+ // The due scan already selects only active contracts; a manual or racing caller that
+ // reaches a non-active one is skipped without parking, so its next-due moment is left
+ // for whichever flow set its status.
if ( ContractStatus::ACTIVE !== $contract->get_status() ) {
wc_get_logger()->info(
sprintf( 'RenewalEngine::process(): contract %d is %s, not active - skipping renewal. No order created.', $contract_id, $contract->get_status() ),
@@ -510,7 +517,7 @@ final class RenewalEngine {
return null;
}
- if ( $head->get_status()->equals( CycleStatus::pending() ) && $this->lease_has_expired( $head, $now ) ) {
+ if ( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) && $this->lease_has_expired( $head, $now ) ) {
// Crash recovery, race-safe: only the caller whose CAS UPDATE matches the
// still-expired row reclaims it; a concurrent worker that already extended the
// lease leaves this caller matching zero rows, so it skips.
@@ -542,7 +549,7 @@ final class RenewalEngine {
// Admin retry: flip a failed head back to pending and re-attempt its charge. Scheduled
// selection never routes a failed head here; only a manual trigger does.
- if ( $head->get_status()->equals( CycleStatus::failed() ) ) {
+ if ( $head->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) ) {
// Race-safe: only the caller whose CAS UPDATE matches the still-failed row wins.
if ( $this->contracts->reclaim_failed_cycle( (int) $head->get_id(), self::LEASE_TTL_SECONDS ) ) {
wc_get_logger()->info(
@@ -722,7 +729,7 @@ final class RenewalEngine {
}
// Sync the entity with the row the CAS just wrote, for the action payload.
$cycle->set_order_id( $order->get_id() );
- $cycle->set_status( CycleStatus::billed() );
+ $cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
$cycle->set_claimed_until_gmt( null );
// Advance to the period actually billed (this cycle's end), not a recomputed one;
@@ -766,7 +773,7 @@ final class RenewalEngine {
return;
}
$cycle->set_order_id( $order->get_id() );
- $cycle->set_status( CycleStatus::failed() );
+ $cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
$cycle->set_reason( 'gateway-charge-failed' );
$cycle->set_claimed_until_gmt( null );
@@ -1087,19 +1094,22 @@ final class RenewalEngine {
* vanished mid-park, a write error) must not stall the rest of the batch. On failure the
* contract simply stays due and the park is re-attempted next tick.
*
+ * Only an active contract is parked: one that stopped being active since it was selected
+ * already left the due set, and its next-due moment belongs to whoever changed its status.
+ *
* @param int $contract_id The contract to remove from the due set.
*/
public function park( int $contract_id ): void {
try {
$contract = $this->contracts->find( $contract_id );
- if ( null === $contract ) {
+ if ( null === $contract || ContractStatus::ACTIVE !== $contract->get_status() ) {
return;
}
$contract->set_next_payment_gmt( null );
- // Conditioned on the status just read: a lifecycle transition racing the
- // park must not be clobbered - the contract is out of the due set either way.
- $this->contracts->update_if_status( $contract, $contract->get_status() );
+ // Conditioned on active: a status change racing the park must not be
+ // clobbered, nor have its next-due moment cleared.
+ $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE );
} catch ( Throwable $e ) {
wc_get_logger()->error(
sprintf( 'RenewalEngine::park(): failed to park contract %d - %s', $contract_id, $e->getMessage() ),
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 27abb9f8eb0..4240f5d5fea 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -422,7 +422,7 @@ final class ContractRepository {
$params = array();
$status = isset( $args['status'] ) && is_string( $args['status'] ) ? $args['status'] : '';
- if ( '' !== $status && ContractStatus::is_valid( $status ) ) {
+ if ( '' !== $status && ContractStatus::is_registered( $status ) ) {
$clauses[] = 'status = %s';
$params[] = $status;
}
@@ -498,13 +498,14 @@ final class ContractRepository {
/**
* The contract count per status - the views bar's read. One `GROUP BY status` scan,
- * returned as a map keyed by EVERY {@see ContractStatus::all()} value (absent statuses
- * filled with 0) and in that order, so a consumer can render a fixed set of views
- * without knowing which statuses currently have rows. The `All` total is the caller's
- * `array_sum()`. Independent of any search / paging (WC-style: the views count the whole
- * store, not the current page).
- *
- * @return array<string, int> Status => count, every known status present.
+ * returned as a map keyed by EVERY registered contract status ({@see ContractStatus::get_all()}:
+ * the engine defaults plus extension registrations; absent statuses filled with 0) and in
+ * that order, so a consumer can render a fixed set of views without knowing which statuses
+ * currently have rows. A stored status that is not registered is not counted as a key. The
+ * `All` total is the caller's `array_sum()`. Independent of any search / paging (WC-style:
+ * the views count the whole store, not the current page).
+ *
+ * @return array<string, int> Status => count, every registered status present.
*/
public function count_by_status(): array {
global $wpdb;
@@ -514,9 +515,9 @@ final class ContractRepository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS total FROM {$table} GROUP BY status", ARRAY_A );
- // Seed every known status at 0 so the map is complete and stably ordered.
+ // Seed every registered status at 0 so the map is complete and stably ordered.
$counts = array();
- foreach ( ContractStatus::all() as $status ) {
+ foreach ( ContractStatus::get_all() as $status ) {
$counts[ $status ] = 0;
}
@@ -525,8 +526,8 @@ final class ContractRepository {
continue;
}
$status = ScalarCoercion::coerce_string( $row['status'] ?? '' );
- // A row whose status has drifted outside the known set is ignored, not added
- // as a stray key - the map stays exactly ContractStatus::all().
+ // A row whose stored status is not registered is ignored, not added as a
+ // stray key - the map stays exactly ContractStatus::get_all().
if ( array_key_exists( $status, $counts ) ) {
$counts[ $status ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
}
@@ -651,38 +652,74 @@ final class ContractRepository {
/**
* Contracts actionable for renewal at `$now`, oldest-due first - the batch dispatcher's scan.
- * Active, primitive-scheduled contracts whose `next_payment_gmt` has arrived, joined to their
- * head cycle so the scan can filter to the ones actually chargeable now:
- *
- * - head `billed`/`cancelled` and its period has ended (`ends_at_gmt <= now`) -> advance-ready;
- * - head `pending` with an expired crash-recovery lease (`claimed_until <= now`) -> reclaim-ready.
- *
- * A head that is `failed` (awaits dunning), `processing` (awaits its gateway), or `pending`
- * with a live lease is deliberately excluded. Because that filter is in SQL, `LIMIT` counts
- * only actionable rows, so a cluster of non-actionable heads (a stuck gateway, a backlog of
- * declines) cannot occupy the batch and starve healthy renewals behind them. Gateway-scheduled
- * contracts are excluded (the gateway owns their renewal); a null `next_payment_gmt` never
- * matches the `<=` comparison. Driven by the `due_contract (status, next_payment_gmt)` index;
- * the head cycle is joined per candidate via the `chain_seq` UNIQUE index. Returns the head
- * fields selection needs, so the dispatcher does not re-load the head to decide what to bill.
- *
- * @param DateTimeImmutable $now The cutoff moment; contracts due at or before it.
- * @param int $limit Maximum rows to return (the batch size).
+ *
+ * A contract is due when its next-due moment (`next_payment_gmt`) has passed AND its owner
+ * (`extension_slug`) is one of `$owners` - the dispatcher passes the registered consumers.
+ * A contract with a null or unlisted owner waits untouched (its next-due moment is never
+ * rewritten) until its owner is listed; with no owners the scan returns nothing. A null
+ * `next_payment_gmt` never matches the `<=` comparison.
+ *
+ * Interim: moves out of the engine with the renewal flow (the predicates below; the scan
+ * then stays owner-scoped only). Until then the scan's only caller is the engine renewal
+ * flow, so it keeps just what that flow can charge now:
+ *
+ * - the contract is `active` (status is otherwise opaque engine data; a contract in any other
+ * status, including an extension-registered one, is simply not selected and its next-due
+ * moment is left as is);
+ * - the contract is not gateway-scheduled (the gateway owns its renewal);
+ * - its head cycle is chargeable now:
+ *
+ * - head `billed` and its period has ended (`ends_at_gmt <= now`) -> advance-ready;
+ * - head `pending` with an expired crash-recovery lease (`claimed_until <= now`) -> reclaim-ready.
+ *
+ * Any other head (`failed` awaiting dunning, `processing` awaiting its gateway, `pending` with
+ * a live lease) is excluded in SQL, so `LIMIT` counts only actionable rows and a cluster of
+ * non-actionable heads cannot starve healthy renewals. Driven by the
+ * `due_owner (extension_slug, next_payment_gmt)` index (status and `schedule_source` are
+ * residual filters); the head cycle is joined per candidate
+ * via the `chain_seq` UNIQUE index. Returns the head fields selection needs, so the dispatcher
+ * does not re-load the head to decide what to bill.
+ *
+ * @param DateTimeImmutable $now The cutoff moment; contracts due at or before it.
+ * @param int $limit Maximum rows to return (the batch size).
+ * @param array<int, string> $owners Owner slugs whose contracts may be selected.
* @return array<int, RenewalCandidate> Actionable renewal candidates, oldest-due first.
*/
- public function find_due( DateTimeImmutable $now, int $limit ): array {
+ public function find_due( DateTimeImmutable $now, int $limit, array $owners ): array {
if ( $limit < 1 ) {
return array();
}
+ $owners = array_values( array_unique( array_map( 'strval', $owners ) ) );
+ if ( array() === $owners ) {
+ return array();
+ }
+
global $wpdb;
- $contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
- $cycles = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
- $cutoff = $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+ $contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ $cycles = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $cutoff = $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+ $owner_placeholders = implode( ', ', array_fill( 0, count( $owners ), '%s' ) );
+
+ $args = array_merge(
+ array( Cycle::KIND_BILLING, Cycle::KIND_BILLING ),
+ $owners,
+ array(
+ ContractStatus::ACTIVE,
+ Contract::SCHEDULE_SOURCE_GATEWAY,
+ $cutoff,
+ CycleStatus::BILLED,
+ $cutoff,
+ CycleStatus::PENDING,
+ $cutoff,
+ $limit,
+ )
+ );
// Table names cannot be bound, so they are interpolated; every value is a placeholder.
- // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ // The owner placeholder list is generated, one `%s` per registered owner in `$args`.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT c.id AS contract_id, cy.count AS head_count, cy.status AS head_status, cy.ends_at_gmt AS head_ends_at_gmt
@@ -690,27 +727,18 @@ final class ContractRepository {
JOIN {$cycles} cy
ON cy.contract_id = c.id AND cy.kind = %s
AND cy.sequence_no = ( SELECT MAX(s.sequence_no) FROM {$cycles} s WHERE s.contract_id = c.id AND s.kind = %s )
- WHERE c.status = %s AND c.schedule_source <> %s AND c.next_payment_gmt IS NOT NULL AND c.next_payment_gmt <= %s
+ WHERE c.extension_slug IN ( {$owner_placeholders} ) AND c.status = %s AND c.schedule_source <> %s AND c.next_payment_gmt IS NOT NULL AND c.next_payment_gmt <= %s
AND (
( cy.status = %s AND cy.ends_at_gmt <= %s )
OR ( cy.status = %s AND cy.claimed_until IS NOT NULL AND cy.claimed_until <= %s )
)
ORDER BY c.next_payment_gmt ASC, c.id ASC
LIMIT %d",
- Cycle::KIND_BILLING,
- Cycle::KIND_BILLING,
- ContractStatus::ACTIVE,
- Contract::SCHEDULE_SOURCE_GATEWAY,
- $cutoff,
- CycleStatus::BILLED,
- $cutoff,
- CycleStatus::PENDING,
- $cutoff,
- $limit
+ $args
),
ARRAY_A
);
- // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// A failed scan otherwise reads exactly like "nothing due" and renewals stall
// store-wide with no signal; the return stays empty either way.
@@ -963,15 +991,24 @@ final class ContractRepository {
* can race across workers), exactly one caller matches the row and wins; the rest match
* zero rows, so status transitions - and the actions fired on them - happen exactly once.
*
+ * `$to_status` must be a registered cycle status (the write-path allowlist). `$from_status`
+ * is only the CAS predicate and is not validated, so a cycle carrying an unknown stored
+ * status can still be settled out of it.
+ *
* @param int $cycle_id The cycle to settle.
* @param string $from_status The status the caller read; the CAS predicate.
- * @param string $to_status The settled status to write.
+ * @param string $to_status The settled status to write; must be registered.
* @param int $order_id The renewal order carrying the outcome.
* @param string|null $reason Failure reason to record, or null to clear.
+ * @throws \DomainException If `$to_status` is not a registered cycle status.
*/
public function transition_cycle_status( int $cycle_id, string $from_status, string $to_status, int $order_id, ?string $reason = null ): bool {
global $wpdb;
+ if ( ! CycleStatus::is_registered( $to_status ) ) {
+ throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle_status(): cycle status "%s" is not registered.', $to_status ) ) );
+ }
+
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
if ( null === $reason ) {
@@ -1159,7 +1196,8 @@ final class ContractRepository {
/**
* Hydrate a cycle row, attaching typed snapshot value objects only for an in-flight
* (non-terminal) cycle. A settled record keeps its snapshot ids but skips the extra
- * reads to decode their payloads.
+ * reads to decode their payloads. {@see CycleStatus::is_terminal()} is false for an
+ * unknown or extension-registered status, so such a cycle's snapshots are decoded.
*
* @param array<string, mixed> $row Cycle row.
* @return Cycle The hydrated cycle.
@@ -1492,7 +1530,9 @@ final class ContractRepository {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) );
+ if ( false === $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) ) ) {
+ $this->log_meta_write_failure( $contract_id, 'delete' );
+ }
$this->insert_meta( $contract_id, $meta );
}
@@ -1561,7 +1601,7 @@ final class ContractRepository {
// The engine's own contract-meta columns, not post/order meta; the
// slow-meta-query heuristic does not apply.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
- $wpdb->insert(
+ $inserted = $wpdb->insert(
SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
array(
'contract_id' => $contract_id,
@@ -1569,9 +1609,33 @@ final class ContractRepository {
'meta_value' => (string) $value,
)
);
+ if ( false === $inserted ) {
+ $this->log_meta_write_failure( $contract_id, 'insert', (string) $key );
+ }
}
}
+ /**
+ * Log a failed contract-meta write. Meta writes follow the row write without a
+ * transaction, so a failure here leaves the row and its meta out of step; logging it
+ * makes that visible.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $operation The failed operation (`delete` or `insert`).
+ * @param string $meta_key The meta key being inserted, if any.
+ */
+ private function log_meta_write_failure( int $contract_id, string $operation, string $meta_key = '' ): void {
+ global $wpdb;
+
+ wc_get_logger()->error(
+ sprintf( 'ContractRepository: contract meta %s failed for contract %d%s - %s', $operation, $contract_id, '' === $meta_key ? '' : sprintf( ' (key %s)', $meta_key ), $wpdb->last_error ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => $contract_id,
+ )
+ );
+ }
+
/**
* Load line items for a contract.
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
index 76b73801b06..522815db895 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -39,13 +39,17 @@ final class SchemaInstaller {
* contracts for the batch renewal scan.
* 2.3.0 - catalog flatten: drop the plan_groups table; plans lose group_id and
* options, gain merchant_code (UNIQUE).
+ * 2.4.0 - owner-scoped due scan: `due_owner (extension_slug, next_payment_gmt)` replaces
+ * `due_contract (status, next_payment_gmt)` and makes the contracts
+ * `extension_slug` index redundant (dropped); pre-freeze, existing tables must be
+ * recreated to drop the old indexes.
*
* Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
* does not change an existing column's nullability or drop unused ones, so a dev box
* on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
* to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
*/
- private const VERSION = '2.3.0';
+ private const VERSION = '2.4.0';
/**
* Option key tracking the installed schema version.
@@ -222,8 +226,8 @@ final class SchemaInstaller {
) {$collate};";
// The contract row is the live source of truth: the totals and stamps are live
- // values, not caches of cycles. The `due_contract (status, next_payment_gmt)` index
- // keys the batch dispatcher's scan (status equality, then a range on the due date);
+ // values, not caches of cycles. The `due_owner (extension_slug, next_payment_gmt)` index
+ // keys the batch dispatcher's scan (owner equality, then a range on the due date);
// `due` is retained for next-bill-cache lookups keyed the other way. `origin_order_id`
// is NULLABLE (a manual/admin contract has no origin order). There is no generic
// `cycle_count` - counters are per-chain, derived as `MAX(count)` over
@@ -259,9 +263,8 @@ final class SchemaInstaller {
PRIMARY KEY (id),
KEY customer_status (customer_id, status),
KEY due (next_payment_gmt, status),
- KEY due_contract (status, next_payment_gmt),
- KEY origin_order (origin_order_id),
- KEY extension_slug (extension_slug)
+ KEY due_owner (extension_slug, next_payment_gmt),
+ KEY origin_order (origin_order_id)
) {$collate};";
$contract_items_sql = "CREATE TABLE {$contract_items} (
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 70c3ee172ef..20b863c8e76 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
@@ -3,7 +3,7 @@
* 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
- * illegal-transition 409.
+ * precondition 409.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -211,8 +211,9 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
}
public function test_illegal_transition_is_a_conflict(): void {
- // Reactivating an active contract is a no-op (idempotent) - so to force the
- // illegal path, try to hold a cancelled contract.
+ // 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 );
@@ -221,6 +222,32 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
$this->assertSame( 409, $response->get_status() );
}
+ 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 );
+
+ $response = rest_get_server()->dispatch( new WP_REST_Request( 'POST', self::BASE . '/' . $id . '/hold' ) );
+
+ $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.' );
+ }
+
+ 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 );
+
+ $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( 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.' );
+ }
+
/**
* The response body as an array (asserts it is one, narrowing offset access).
*
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 8367fa76492..bd78adfb26f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -285,7 +285,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
$this->seed_list_contract( ContractStatus::ON_HOLD );
$by_status = Subscriptions::count_by_status();
- $this->assertSame( ContractStatus::all(), array_keys( $by_status ) );
+ $this->assertSame( ContractStatus::get_all(), array_keys( $by_status ) );
$this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
$this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
$this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
@@ -345,7 +345,7 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
// Newest first: cycle 2 is billed, linked to the renewal order.
$cycle_two = $history[0];
$this->assertSame( 2, $cycle_two->get_count() );
- $this->assertTrue( $cycle_two->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle_two->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertSame( $renewal_order->get_id(), $cycle_two->get_order_id() );
// The schedule advanced one cadence (cycle 1 ended 2026-02-15 + 1 month).
@@ -468,15 +468,19 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
$held = Subscriptions::get( $contract_id );
$this->assertInstanceOf( Contract::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 = Subscriptions::get( $contract_id );
$this->assertInstanceOf( Contract::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 = Subscriptions::get( $contract_id );
$this->assertInstanceOf( Contract::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/Checkout/ContractFactoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
index 949175199f3..995b2891c41 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
@@ -122,7 +122,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
// Cycle 1 is the origin period: count 1, linked to the origin order, billed.
$this->assertSame( 1, $cycle->get_count() );
$this->assertSame( $order->get_id(), $cycle->get_order_id() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertSame( 'lite', $cycle->get_extension_slug() );
// Its period runs from the paid time to the first renewal 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
index 756c7a8794d..125be196c7a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
@@ -1,9 +1,9 @@
<?php
/**
- * Integration tests for the Cancellation contract operation's period-end mode: ACTIVE ->
- * PENDING_CANCELLATION, the end date stamped, and the next-payment date left in place so
- * the contract lapses at period end. (The immediate-cancel mode is covered through the
- * facade suite.)
+ * 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
*/
@@ -17,7 +17,9 @@ use EngineIntegrationTestCase;
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
@@ -46,10 +48,20 @@ class CancellationTest extends EngineIntegrationTestCase {
* @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(
'customer_id' => 1,
- 'status' => ContractStatus::ACTIVE,
+ 'status' => $status,
'currency' => 'USD',
'selling_plan_id' => 1,
'start_gmt' => '2026-01-01 00:00:00',
@@ -74,14 +86,16 @@ class CancellationTest extends EngineIntegrationTestCase {
$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
}
- public function test_leaves_the_next_payment_in_place_so_the_contract_lapses_at_the_date(): void {
- // The next-payment date is deliberately left in place; the due scan refuses to
- // charge a non-active contract, so no renewal fires while it winds down.
+ 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 ) );
- $this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt(), 'The contract lapses at the date; the next-payment date stays in place.' );
+ $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 {
@@ -89,7 +103,43 @@ class CancellationTest extends EngineIntegrationTestCase {
$this->sut->cancel_at_period_end( $this->reload( $id ) );
- $this->assertSame( '2026-09-09 00:00:00', $this->reload( $id )->get_end_gmt() );
+ $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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
+ 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 {
@@ -124,6 +174,152 @@ class CancellationTest extends EngineIntegrationTestCase {
$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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
+ 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_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( $this->with_hold_anchor( $this->reload( $id ), '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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
+ 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( $this->with_hold_anchor( $held, '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.' );
+ }
+
+ /**
+ * Set the hold anchor meta on a contract.
+ *
+ * @param Contract $contract Contract to change.
+ * @param string $anchor Anchor value to store.
+ */
+ private function with_hold_anchor( Contract $contract, string $anchor ): Contract {
+ $contract->set_meta( Hold::ANCHOR_META_KEY, $anchor );
+
+ return $contract;
+ }
+
+ 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).
*
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
index 434fc58c2ae..5880d933451 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
@@ -1,7 +1,7 @@
<?php
/**
* Integration tests for the Hold contract operation: ACTIVE -> ON_HOLD, the
- * next-payment date preserved, and the held action fired.
+ * next-due moment disarmed (kept as the hold anchor), and the held action fired.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -16,6 +16,7 @@ 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
@@ -39,11 +40,12 @@ class HoldTest extends EngineIntegrationTestCase {
}
/**
- * Seed a contract at a status with a future next-payment date.
+ * Seed a contract at a status with a next-payment date (a future one by default).
*
- * @param string $status Contract status.
+ * @param string $status Contract status.
+ * @param string|null $next_payment Next-payment GMT string, or null.
*/
- private function seed( string $status ): int {
+ private function seed( string $status, ?string $next_payment = '2099-01-01 00:00:00' ): int {
$contract = Contract::create(
array(
'customer_id' => 1,
@@ -51,7 +53,7 @@ class HoldTest extends EngineIntegrationTestCase {
'currency' => 'USD',
'selling_plan_id' => 1,
'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2099-01-01 00:00:00',
+ 'next_payment_gmt' => $next_payment,
'billing_total' => '19.99',
)
);
@@ -65,16 +67,51 @@ class HoldTest extends EngineIntegrationTestCase {
$result = $this->sut->hold( $this->reload( $id ) );
$this->assertTrue( $result );
- // No charge while held: the contract is on hold, and the batch due scan only bills active contracts.
+ // 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_keeps_the_next_payment_for_a_later_reactivate(): void {
+ public function test_hold_clears_the_next_payment_and_keeps_the_anchor(): void {
$id = $this->seed( ContractStatus::ACTIVE );
$this->sut->hold( $this->reload( $id ) );
- $this->assertSame( '2099-01-01 00:00:00', $this->reload( $id )->get_next_payment_gmt() );
+ $held = $this->reload( $id );
+ $this->assertNull( $held->get_next_payment_gmt() );
+ $this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
+ }
+
+ 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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $held->get_meta() );
+ }
+
+ 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', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
}
public function test_hold_fires_the_held_action(): void {
@@ -110,6 +147,33 @@ class HoldTest extends EngineIntegrationTestCase {
}
}
+ /**
+ * 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 );
@@ -117,6 +181,29 @@ class HoldTest extends EngineIntegrationTestCase {
$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).
*
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
index ecc0452b01e..606d1c1d4d1 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
@@ -20,9 +20,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Reactivation
@@ -82,11 +84,16 @@ class ReactivationTest extends EngineIntegrationTestCase {
/**
* Seed a contract with a next-payment date and the given selling plan.
*
- * @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.
+ * 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 ): int {
+ 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(
'customer_id' => 1,
@@ -97,6 +104,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
'start_gmt' => '2026-01-01 00:00:00',
'next_payment_gmt' => $next_payment_gmt,
'billing_total' => '19.99',
+ 'meta' => $meta,
)
);
@@ -138,6 +146,55 @@ class ReactivationTest extends EngineIntegrationTestCase {
$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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
+ 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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
+ /**
+ * 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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
+ 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->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ }
+
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
@@ -258,6 +315,36 @@ class ReactivationTest extends EngineIntegrationTestCase {
$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).
*
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
new file mode 100644
index 00000000000..c5380f92158
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -0,0 +1,256 @@
+<?php
+/**
+ * Integration tests for the owner-scoped due scan, end to end through the batch
+ * dispatcher: a contract is due when its next-due moment has passed and its owner is a
+ * registered consumer. Lifecycle flows stop renewals by disarming the next-due moment; the
+ * interim renewal-flow status predicate leaves a contract in any other status (including an
+ * extension-registered one) unselected and untouched; a contract whose owner is null or
+ * unregistered waits untouched.
+ *
+ * Note: actually terminating a pending-cancellation contract at its end date (moving it
+ * terminal) is a follow-up slice; today it simply has no next-due moment.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use EngineIntegrationTestCase;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository::find_due
+ */
+class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
+
+ /**
+ * A gateway that approves the scheduled charge inline.
+ */
+ private const GATEWAY = 'engine_owner_scan_gateway';
+
+ /**
+ * The registered consumer, and the owner the default test plan carries.
+ */
+ private const OWNER = 'engine-tests';
+
+ /**
+ * The date signed-up contracts first come due (one month after the paid date).
+ */
+ private const FIRST_DUE = '2026-02-15 00:00:00';
+
+ /**
+ * @var ContractRepository
+ */
+ private $contracts;
+
+ public function set_up(): void {
+ parent::set_up();
+ GatewayCapabilities::reset();
+ ConsumerRegistry::reset();
+ ConsumerRegistry::register( self::OWNER );
+ $this->approve_charges_for( self::GATEWAY );
+
+ $this->contracts = new ContractRepository();
+ }
+
+ public function tear_down(): void {
+ ConsumerRegistry::reset();
+ StatusRegistry::reset();
+ GatewayCapabilities::reset();
+ parent::tear_down();
+ }
+
+ 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 ) );
+
+ $this->run_batch_at( self::FIRST_DUE );
+ $this->run_batch_at( '2026-06-01 00:00:00' );
+
+ $this->assertSame( 0, $this->renewal_order_count( $id ) );
+ $held = $this->reload( $id );
+ $this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
+ $this->assertNull( $held->get_next_payment_gmt() );
+ }
+
+ 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 ) );
+
+ $this->run_batch_at( self::FIRST_DUE );
+
+ $this->assertSame( 0, $this->renewal_order_count( $id ) );
+ $stored = $this->reload( $id );
+ $this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
+ $this->assertSame( self::FIRST_DUE, $stored->get_end_gmt() );
+ }
+
+ public function test_a_due_contract_with_an_extension_registered_status_is_not_selected_and_left_untouched(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+ $id = $this->sign_up( self::OWNER );
+ $this->force_column( $id, 'status', 'paused-by-merchant' );
+
+ $at = new DateTimeImmutable( '2026-02-20 00:00:00', new DateTimeZone( 'UTC' ) );
+ $this->assertNotContains( $id, $this->due_ids( $at ), 'The scan does not select a non-active contract.' );
+
+ $this->run_batch_at( '2026-02-20 00:00:00' );
+
+ // The engine never clears the due moment of a status it did not set: the extension
+ // that set it decides what happens next.
+ $this->assertSame( 0, $this->renewal_order_count( $id ) );
+ $stored = $this->reload( $id );
+ $this->assertSame( 'paused-by-merchant', $stored->get_status() );
+ $this->assertSame( self::FIRST_DUE, $stored->get_next_payment_gmt() );
+ }
+
+ public function test_a_due_contract_with_an_unregistered_owner_waits_until_the_owner_registers(): void {
+ $id = $this->sign_up( 'other-ext' );
+
+ $this->run_batch_at( '2026-02-20 00:00:00' );
+
+ $this->assertSame( 0, $this->renewal_order_count( $id ) );
+ $this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt(), 'The waiting contract is untouched.' );
+
+ ConsumerRegistry::register( 'other-ext' );
+ $this->run_batch_at( '2026-02-20 00:00:00' );
+
+ $this->assertSame( 1, $this->renewal_order_count( $id ) );
+ }
+
+ public function test_a_due_contract_with_no_owner_is_untouched(): void {
+ $id = $this->sign_up( self::OWNER );
+ $this->force_column( $id, 'extension_slug', null );
+
+ $this->run_batch_at( '2026-02-20 00:00:00' );
+
+ $this->assertSame( 0, $this->renewal_order_count( $id ) );
+ $this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt() );
+ }
+
+ /**
+ * Sign up a monthly contract owned by `$owner` via the checkout factory (cycle 1 billed,
+ * next payment due {@see self::FIRST_DUE}). Returns the contract id.
+ *
+ * @param string $owner The plan's (and so the contract's) extension slug.
+ */
+ private function sign_up( string $owner ): int {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Monthly',
+ 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+ 'category' => Plan::DEFAULT_CATEGORY,
+ 'extension_slug' => $owner,
+ )
+ );
+ ( new PlanRepository() )->insert( $plan );
+
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( self::GATEWAY );
+ $order->set_total( '19.99' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ $order->save();
+
+ $contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+ $id = (int) $contract->get_id();
+ $this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt() );
+
+ return $id;
+ }
+
+ /**
+ * Run one dispatcher tick at the given UTC moment.
+ *
+ * @param string $now GMT moment.
+ */
+ private function run_batch_at( string $now ): void {
+ ( new RenewalDispatcher() )->run_batch( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ), 50 );
+ }
+
+ /**
+ * Contract ids the due scan selects at `$at`.
+ *
+ * @param DateTimeImmutable $at Scan moment.
+ * @return array<int, int>
+ */
+ private function due_ids( DateTimeImmutable $at ): array {
+ $ids = array();
+ foreach ( $this->contracts->find_due( $at, 50, ConsumerRegistry::all() ) as $candidate ) {
+ $ids[] = $candidate->get_contract_id();
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Overwrite one contract column directly (a shape no flow produces).
+ *
+ * @param int $id Contract id.
+ * @param string $column Column name.
+ * @param string|null $value Value to store.
+ */
+ private function force_column( int $id, string $column, ?string $value ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( $column => $value ), array( 'id' => $id ) );
+ }
+
+ /**
+ * Count renewal orders tagged for a contract.
+ *
+ * @param int $contract_id Contract id.
+ */
+ private function renewal_order_count( int $contract_id ): int {
+ $orders = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ 'type' => 'shop_order',
+ 'meta_key' => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => (string) $contract_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ )
+ );
+
+ $count = 0;
+ foreach ( is_array( $orders ) ? $orders : array() as $order ) {
+ if ( $order instanceof WC_Order && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
+ ++$count;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * 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/Renewal/RenewalDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
index 85f3139de7f..660dfba037e 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -38,9 +38,11 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
private const GATEWAY_APPROVING = 'engine_dispatch_gateway_approve';
/**
- * The consumer slug registered to open the processing gate in charging tests.
+ * The consumer slug registered in charging tests. It is the owner the test plan (and
+ * therefore every contract signed up from it) carries, so registering it puts those
+ * contracts in the owner-scoped due scan.
*/
- private const CONSUMER = 'engine-tests-consumer';
+ private const CONSUMER = 'engine-tests';
public function set_up(): void {
parent::set_up();
@@ -153,7 +155,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
// Cycle 2 was billed via the dummy gateway and the schedule advanced one cadence.
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$reloaded = $repo->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
@@ -326,7 +328,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
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 097cc2d318a..42f7d0be5bc 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
@@ -27,6 +27,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
@@ -169,6 +170,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'currency' => 'USD',
'selling_plan_id' => $plan_id,
'origin_order_id' => $origin_order_id,
+ 'extension_slug' => 'engine-tests',
'payment_method' => self::GATEWAY,
'start_gmt' => '2026-01-15 00:00:00',
'next_payment_gmt' => '2026-02-15 00:00:00',
@@ -249,7 +251,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'contract_id' => $contract_id,
'sequence_no' => $previous->get_sequence_no() + 1,
'count' => 2,
- 'status' => CycleStatus::pending(),
+ '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',
@@ -309,7 +311,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// SAME cycle row reclaimed (not a duplicate), now billed with the lease cleared.
$this->assertSame( $stalled_id, $head->get_id() );
$this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertNull( $head->get_claimed_until_gmt() );
// The schedule advances to the RECLAIMED cycle's own end (2026-03-15), not a
@@ -360,7 +362,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( $claimed->get_id(), $head->get_id() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
$this->assertSame( $live_until, $head->get_claimed_until_gmt() );
}
@@ -389,7 +391,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_sequence_no() );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );
$this->assertSame( '19.99000000', $cycle->get_expected_total() );
$this->assertSame( 'engine-tests', $cycle->get_extension_slug() );
@@ -489,7 +491,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// charge did not settle (for dunning + admin visibility).
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::failed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) );
$this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );
// The contract schedule is untouched (left for dunning), still active.
@@ -571,7 +573,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$cycle = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -630,7 +632,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'contract_id' => $contract_id,
'sequence_no' => 1,
'count' => 1,
- 'status' => CycleStatus::billed(),
+ 'status' => new CycleStatus( CycleStatus::BILLED ),
'starts_at_gmt' => '2026-01-15 00:00:00',
'ends_at_gmt' => '2026-02-15 00:00:00',
'expected_total' => '19.99',
@@ -691,7 +693,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( $stalled_id, $head->get_id() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertNull( $head->get_claimed_until_gmt() );
// The schedule advances to the reclaimed cycle's own end - one cadence, not skipped.
@@ -743,7 +745,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$reloaded = $repo->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
@@ -773,7 +775,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -784,22 +786,108 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox the scheduled scan skips a non-active contract and creates no renewal order.
+ * @testdox the due scan excludes a past-due non-active contract: no renewal order, its next-due moment untouched.
+ *
+ * A shape no flow produces (on hold while still carrying a due moment), seeded here. The
+ * scan's interim `active` predicate keeps it out of the batch, so neither `process()` nor
+ * `park()` is reached; those paths are pinned by the `renew_now` and `park` tests below.
*/
- public function test_scheduled_renewal_skips_non_active_contract(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+ public function test_due_scan_excludes_a_non_active_contract_and_leaves_it_untouched(): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
- $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 );
- $contract->set_status( ContractStatus::ON_HOLD );
- ( new ContractRepository() )->update( $contract );
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = (int) $contract->get_id();
+ $this->force_status( $contract_id, ContractStatus::ON_HOLD );
+
+ $repo = new ContractRepository();
+ $before = $repo->find_chain_head( $contract_id );
+ $next_payment = $this->reload_contract( $contract_id )->get_next_payment_gmt();
+ $this->assertInstanceOf( Cycle::class, $before );
+ $this->assertNotNull( $next_payment, 'Seeded with a due moment.' );
$this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+ $after = $repo->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $after );
+ $this->assertSame( $before->get_id(), $after->get_id(), 'No successor cycle was claimed.' );
+ $skipped = $this->reload_contract( $contract_id );
+ $this->assertSame( ContractStatus::ON_HOLD, $skipped->get_status() );
+ $this->assertSame( $next_payment, $skipped->get_next_payment_gmt(), 'The non-active contract is not parked.' );
+ }
+
+ /**
+ * @testdox renew_now returns null for a non-active contract and does not park it.
+ */
+ public function test_renew_now_returns_null_for_a_non_active_contract_without_parking(): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = (int) $contract->get_id();
+ $this->force_status( $contract_id, ContractStatus::ON_HOLD );
+ $next_payment = $this->reload_contract( $contract_id )->get_next_payment_gmt();
+
+ $this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
+
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+ $this->assertSame( $next_payment, $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'A manual renew never parks.' );
+ }
+
+ /**
+ * @testdox park leaves a contract that is no longer active untouched.
+ *
+ * A contract can stop being active between being selected and being parked; its next-due
+ * moment then belongs to whoever changed the status, so the park is a no-op.
+ */
+ public function test_park_leaves_a_non_active_contract_untouched(): void {
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = (int) $contract->get_id();
+ $this->force_status( $contract_id, ContractStatus::ON_HOLD );
+ $next_payment = $this->reload_contract( $contract_id )->get_next_payment_gmt();
+ $this->assertNotNull( $next_payment, 'Seeded with a due moment.' );
+
+ ( new RenewalEngine() )->park( $contract_id );
+
+ $this->assertSame( $next_payment, $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+ }
+
+ /**
+ * @testdox park clears the next-due moment of an active contract.
+ */
+ public function test_park_clears_the_next_payment_of_an_active_contract(): void {
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = (int) $contract->get_id();
+ $this->assertNotNull( $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'Seeded with a due moment.' );
+
+ ( new RenewalEngine() )->park( $contract_id );
+
+ $this->assertNull( $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+ }
+
+ /**
+ * Overwrite a contract's stored status directly, keeping every other column (a shape no
+ * flow produces, e.g. an on-hold contract that still has a next-due moment).
+ *
+ * @param int $contract_id Contract id.
+ * @param string $status Status to store.
+ */
+ private function force_status( int $contract_id, string $status ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'status' => $status ), array( 'id' => $contract_id ) );
+ }
+
+ /**
+ * Reload a contract, asserting it still exists.
+ *
+ * @param int $contract_id Contract id.
+ */
+ private function reload_contract( int $contract_id ): Contract {
+ $contract = ( new ContractRepository() )->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $contract );
+
+ return $contract;
}
/**
@@ -837,10 +925,10 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox cancel transitions the contract to cancelled.
+ * @testdox cancel transitions the contract to cancelled and disarms its next-due moment.
*
- * The due scan only selects active contracts, so cancellation needs no schedule
- * cleanup - the status transition alone removes the contract from renewal.
+ * 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 ) );
@@ -856,6 +944,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$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() );
}
/**
@@ -875,7 +964,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'contract_id' => $contract_id,
'sequence_no' => $previous->get_sequence_no() + 1,
'count' => 2,
- 'status' => CycleStatus::pending(),
+ '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',
@@ -893,7 +982,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::cancelled() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::CANCELLED ) ) );
}
/**
@@ -909,7 +998,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// 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( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
@@ -939,7 +1028,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$cycle = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::processing() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PROCESSING ) ) );
// A processing cycle carries no crash-recovery lease.
$this->assertNull( $cycle->get_claimed_until_gmt() );
@@ -974,7 +1063,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$repo = new ContractRepository();
$processing_head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $processing_head );
- $this->assertTrue( $processing_head->get_status()->equals( CycleStatus::processing() ) );
+ $this->assertTrue( $processing_head->get_status()->equals( new CycleStatus( CycleStatus::PROCESSING ) ) );
// The async confirmation arrives: the order is paid. Drive completion as the listener would.
$order->payment_complete();
@@ -984,7 +1073,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$reloaded = $repo->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
@@ -994,7 +1083,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$engine->complete_from_order( $paid_order );
$billed_head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $billed_head );
- $this->assertTrue( $billed_head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $billed_head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -1020,7 +1109,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
// The scheduled path before cycle 2's period ends: selection skips, so nothing advances.
$ahead = $this->run_scheduled_renewal( $contract_id, new \DateTimeImmutable( '2026-03-01 00:00:00', new \DateTimeZone( 'UTC' ) ) );
@@ -1058,7 +1147,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -1087,7 +1176,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$cycle = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
// Schedule preserved: cycle 2 runs from cycle 1's end (2026-02-15), so next payment is one
// cadence on from that (2026-03-15) - not one cadence from the manual-renewal moment.
@@ -1119,7 +1208,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$failed = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $failed );
$this->assertSame( 2, $failed->get_count() );
- $this->assertTrue( $failed->get_status()->equals( CycleStatus::failed() ) );
+ $this->assertTrue( $failed->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) );
// The customer fixes their payment method; the same gateway now approves the retry.
remove_all_actions( 'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_DECLINING );
@@ -1144,7 +1233,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
// The failed order was reused, not duplicated.
$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
@@ -1204,7 +1293,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -1236,7 +1325,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertSame( $draft->get_id(), $head->get_order_id(), 'The cycle link is healed from the meta match.' );
}
@@ -1268,7 +1357,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertSame( $renewal_order->get_id(), $head->get_order_id(), 'The cycle is linked to its own order, not the stray.' );
$stray_after = wc_get_order( $stray->get_id() );
@@ -1350,7 +1439,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::pending() ), 'The linked head is not settled by a rogue order.' );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ), 'The linked head is not settled by a rogue order.' );
$this->assertSame( $order_a->get_id(), $head->get_order_id() );
}
@@ -1380,7 +1469,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::pending() ), 'No settlement happens from a stale order copy.' );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ), 'No settlement happens from a stale order copy.' );
}
/**
@@ -1456,14 +1545,14 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$repo = new ContractRepository();
$head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( CycleStatus::processing() ) );
+ $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PROCESSING ) ) );
// An admin marks the order paid by hand; the status-transition listener settles.
$renewal_order->update_status( 'processing' );
$settled = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $settled );
- $this->assertTrue( $settled->get_status()->equals( CycleStatus::billed() ), 'The manual paid transition bills the cycle.' );
+ $this->assertTrue( $settled->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ), 'The manual paid transition bills the cycle.' );
// The schedule advanced to the billed cycle's own period end.
$reloaded = $repo->find( $contract_id );
@@ -1678,7 +1767,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'contract_id' => $contract_id,
'sequence_no' => $previous->get_sequence_no() + 1,
'count' => 2,
- 'status' => CycleStatus::pending(),
+ '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',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php
deleted file mode 100644
index 5510c34462c..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/StatusAwareDueScanTest.php
+++ /dev/null
@@ -1,168 +0,0 @@
-<?php
-/**
- * Integration tests for the status-aware due scan: a non-active contract is never
- * charged when its renewal fires. An on-hold contract is skipped (no charge while
- * held) and a pending-cancellation contract creates no renewal order at its date.
- *
- * Note: actually terminating a pending-cancellation contract at the date (moving it
- * terminal) is a follow-up slice; the current dispatcher only refuses to charge it.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Renewal;
-
-use EngineIntegrationTestCase;
-use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
- */
-class StatusAwareDueScanTest extends EngineIntegrationTestCase {
-
- private const GATEWAY = 'engine_test_gateway';
-
- /**
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * @var RenewalEngine
- */
- private $engine;
-
- public function set_up(): void {
- parent::set_up();
- GatewayCapabilities::reset();
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
- $this->contracts = new ContractRepository();
- $this->engine = new RenewalEngine( $this->contracts );
- }
-
- public function tear_down(): void {
- GatewayCapabilities::reset();
- parent::tear_down();
- }
-
- private function make_origin_order(): WC_Order {
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( self::GATEWAY );
- $order->set_total( '19.99' );
- $order->save();
-
- return $order;
- }
-
- /**
- * Seed a contract at a status with a due (past) next-payment date.
- *
- * @param string $status Contract status.
- */
- private function seed_due( string $status ): int {
- $order = $this->make_origin_order();
- $contract = Contract::create(
- array(
- 'customer_id' => 1,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'origin_order_id' => $order->get_id(),
- 'payment_method' => self::GATEWAY,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2026-02-01 00:00:00',
- 'end_gmt' => ContractStatus::PENDING_CANCELLATION === $status ? '2026-02-01 00:00:00' : null,
- 'billing_total' => '19.99',
- )
- );
-
- return $this->contracts->insert( $contract );
- }
-
- /**
- * Process a renewal for the contract, mirroring how the batch dispatcher hands the
- * engine a resolved intent for the due contract. The non-active status guard in
- * {@see RenewalEngine::process()} short-circuits ahead of any cycle selection, so the
- * exact cycle count handed in is immaterial here.
- *
- * @param int $contract_id Contract id.
- */
- private function process_renewal( int $contract_id ): ?WC_Order {
- return $this->engine->process(
- new RenewalIntent( $contract_id, 1 ),
- new \DateTimeImmutable( '2026-02-01 00:00:00', new \DateTimeZone( 'UTC' ) )
- );
- }
-
- /**
- * Count renewal orders tagged for a contract.
- *
- * @param int $contract_id Contract id.
- */
- private function renewal_order_count( int $contract_id ): int {
- $orders = wc_get_orders(
- array(
- 'limit' => -1,
- 'status' => 'any',
- 'type' => 'shop_order',
- 'meta_key' => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
- 'meta_value' => (string) $contract_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
- )
- );
-
- $count = 0;
- foreach ( is_array( $orders ) ? $orders : array() as $order ) {
- if ( $order instanceof WC_Order && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
- ++$count;
- }
- }
-
- return $count;
- }
-
- public function test_on_hold_contract_at_its_date_creates_no_renewal_order(): void {
- $id = $this->seed_due( ContractStatus::ON_HOLD );
-
- $result = $this->process_renewal( $id );
-
- $this->assertNull( $result );
- $this->assertSame( 0, $this->renewal_order_count( $id ) );
- // Still on hold; the next-payment date is left for reactivate to re-arm.
- $this->assertSame( ContractStatus::ON_HOLD, $this->reload( $id )->get_status() );
- }
-
- public function test_pending_cancellation_at_its_date_creates_no_renewal_order(): void {
- $id = $this->seed_due( ContractStatus::PENDING_CANCELLATION );
-
- $result = $this->process_renewal( $id );
-
- $this->assertNull( $result, 'No renewal order is created for a non-active contract.' );
- $this->assertSame( 0, $this->renewal_order_count( $id ) );
- // The contract is not charged. Terminating it at the date is a follow-up slice,
- // so it remains pending-cancellation for now.
- $this->assertSame( ContractStatus::PENDING_CANCELLATION, $this->reload( $id )->get_status() );
- }
-
- /**
- * 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/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index a0a0c0d6908..767b88862b9 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -33,11 +34,21 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
*/
private $sut;
+ /**
+ * The owner the due-scan fixtures carry, registered as a consumer in setUp().
+ */
+ private const OWNER = 'engine-tests';
+
public function setUp(): void {
parent::setUp();
$this->sut = new ContractRepository();
}
+ public function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
+ }
+
private function make_contract(): Contract {
return Contract::create(
array(
@@ -498,8 +509,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$counts = $this->sut->count_by_status();
- // Every known status is a key, in ContractStatus::all() order, with absent ones 0.
- $this->assertSame( ContractStatus::all(), array_keys( $counts ) );
+ // Every known status is a key, in ContractStatus::get_all() order, with absent ones 0.
+ $this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
$this->assertSame( 2, $counts[ ContractStatus::ACTIVE ] );
$this->assertSame( 1, $counts[ ContractStatus::ON_HOLD ] );
$this->assertSame( 0, $counts[ ContractStatus::PENDING_CANCELLATION ] );
@@ -513,7 +524,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
public function test_count_by_status_is_all_zero_when_empty(): void {
$counts = $this->sut->count_by_status();
- $this->assertSame( ContractStatus::all(), array_keys( $counts ) );
+ $this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
$this->assertSame( array( 0, 0, 0, 0, 0 ), array_values( $counts ) );
}
@@ -622,7 +633,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
public function test_insert_with_origin_cycle_records_refs_on_the_contract(): void {
$contract = $this->make_contract();
$cycle = $this->make_cycle( 0, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot(), 1001 );
- $cycle->set_status( CycleStatus::billed() );
+ $cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
$id = $this->sut->insert_with_origin_cycle( $contract, $cycle );
$this->assertGreaterThan( 0, $id );
@@ -642,7 +653,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$current = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $current );
$this->assertSame( 1, $current->get_count() );
- $this->assertTrue( $current->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $current->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -797,7 +808,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( $cycle->get_id(), $current->get_id() );
$this->assertSame( 1, $current->get_sequence_no() );
$this->assertSame( 1, $current->get_count() );
- $this->assertTrue( $current->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertTrue( $current->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
$this->assertSame( '2026-07-15 00:00:00', $current->get_starts_at_gmt() );
$this->assertSame( '19.99000000', $current->get_expected_total() );
$this->assertSame( 'lite', $current->get_extension_slug() );
@@ -946,12 +957,12 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
$this->sut->append_cycle( $cycle );
- $cycle->set_status( CycleStatus::billed() );
+ $cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
$this->sut->update_cycle( $cycle );
$reloaded = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $reloaded );
- $this->assertTrue( $reloaded->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $reloaded->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
/**
@@ -966,7 +977,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'contract_id' => $id,
'sequence_no' => 1,
'count' => 1,
- 'status' => CycleStatus::pending(),
+ 'status' => new CycleStatus( CycleStatus::PENDING ),
'starts_at_gmt' => '2026-07-15 00:00:00',
'ends_at_gmt' => '2026-08-15 00:00:00',
'expected_total' => '19.99',
@@ -981,7 +992,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-07-15 00:15:00', $reloaded->get_claimed_until_gmt() );
// Cleared on update (a settled cycle holds no lease).
- $reloaded->set_status( CycleStatus::billed() );
+ $reloaded->set_status( new CycleStatus( CycleStatus::BILLED ) );
$reloaded->set_claimed_until_gmt( null );
$this->sut->update_cycle( $reloaded );
@@ -1047,7 +1058,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$cycle = $this->append_pending_cycle_with_lease( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
// Settle it billed (clearing the lease, as the money-path does).
- $cycle->set_status( CycleStatus::billed() );
+ $cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
$cycle->set_claimed_until_gmt( null );
$this->sut->update_cycle( $cycle );
@@ -1067,7 +1078,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'contract_id' => $contract_id,
'sequence_no' => 1,
'count' => 1,
- 'status' => CycleStatus::pending(),
+ 'status' => new CycleStatus( CycleStatus::PENDING ),
'starts_at_gmt' => '2026-07-15 00:00:00',
'ends_at_gmt' => '2026-08-15 00:00:00',
'expected_total' => '19.99',
@@ -1093,12 +1104,66 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$ids = $this->due_ids( $now, 50 );
- // Only the two due+active contracts, oldest-due first; the future and the non-active excluded.
+ // Only the two due+active contracts, oldest-due first; the future and the past-due
+ // on-hold row excluded (the interim renewal-flow status predicate).
$this->assertSame( array( $due_old, $due_recent ), $ids );
$this->assertNotContains( $not_yet, $ids );
$this->assertNotContains( $on_hold, $ids );
}
+ /**
+ * @testdox find_due skips a due contract whose stored status is not registered.
+ */
+ public function test_find_due_skips_a_contract_with_an_unregistered_stored_status(): void {
+ global $wpdb;
+
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', 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 ) );
+
+ $this->assertSame( array(), $this->due_ids( $now, 50 ) );
+ }
+
+ /**
+ * @testdox find_due skips a due contract with no owner.
+ */
+ public function test_find_due_skips_a_contract_with_no_owner(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ $owned = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+ $no_owner = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, null, null );
+
+ $ids = $this->due_ids( $now, 50 );
+
+ $this->assertContains( $owned, $ids );
+ $this->assertNotContains( $no_owner, $ids );
+ }
+
+ /**
+ * @testdox find_due skips a contract whose owner is not registered, and selects it untouched once it registers.
+ */
+ public function test_find_due_skips_an_unregistered_owner_until_it_registers(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, null, 'other-ext' );
+
+ $this->assertNotContains( $id, $this->due_ids( $now, 50 ) );
+
+ $this->assertContains( $id, $this->due_ids( $now, 50, array( self::OWNER, 'other-ext' ) ) );
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $this->assertSame( '2026-06-15 00:00:00', $contract->get_next_payment_gmt(), 'The waiting contract keeps its due moment.' );
+ }
+
+ /**
+ * @testdox find_due returns nothing when no consumer is registered.
+ */
+ public function test_find_due_returns_nothing_when_no_consumer_is_registered(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+
+ $this->assertSame( array(), $this->sut->find_due( $now, 50, array() ) );
+ }
+
/**
* @testdox find_due treats the cutoff as inclusive and excludes a null next_payment.
*/
@@ -1150,7 +1215,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
$id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $candidates = $this->sut->find_due( $now, 50 );
+ $candidates = $this->sut->find_due( $now, 50, array( self::OWNER ) );
$this->assertCount( 1, $candidates );
$row = $candidates[0];
@@ -1213,8 +1278,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
$this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $this->assertSame( array(), $this->sut->find_due( $now, 0 ) );
- $this->assertSame( array(), $this->sut->find_due( $now, -1 ) );
+ $this->assertSame( array(), $this->sut->find_due( $now, 0, array( self::OWNER ) ) );
+ $this->assertSame( array(), $this->sut->find_due( $now, -1, array( self::OWNER ) ) );
}
/**
@@ -1246,19 +1311,125 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( 4242, $after->get_order_id() );
}
+ /**
+ * @testdox transition_cycle_status rejects an unregistered target status and writes nothing.
+ */
+ public function test_transition_cycle_status_rejects_an_unregistered_target(): void {
+ $contract_id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING );
+
+ $head = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+
+ try {
+ $this->sut->transition_cycle_status( (int) $head->get_id(), CycleStatus::PENDING, 'never-registered', 4242 );
+ $this->fail( 'Expected a DomainException for an unregistered target status.' );
+ } catch ( \DomainException $e ) {
+ $after = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $after );
+ $this->assertSame( CycleStatus::PENDING, $after->get_status()->get_value() );
+ }
+ }
+
+ /**
+ * @testdox transition_cycle_status accepts an extension-registered target status.
+ */
+ public function test_transition_cycle_status_accepts_an_extension_registered_target(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+ $contract_id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING );
+
+ $head = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+
+ $this->assertTrue( $this->sut->transition_cycle_status( (int) $head->get_id(), CycleStatus::PENDING, 'disputed', 4242 ) );
+
+ $after = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $after );
+ $this->assertSame( 'disputed', $after->get_status()->get_value() );
+ }
+
+ /**
+ * @testdox An unknown stored contract status hydrates and survives an unrelated update.
+ */
+ public function test_an_unknown_stored_contract_status_round_trips(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( $table, array( 'status' => 'legacy-paused' ), array( 'id' => $id ) );
+
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $this->assertSame( 'legacy-paused', $contract->get_status() );
+
+ $contract->set_next_payment_gmt( '2026-09-15 00:00:00' );
+ $this->assertTrue( $this->sut->update( $contract ) );
+ $this->assertTrue( $this->sut->update_if_status( $contract, 'legacy-paused' ) );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $stored = $wpdb->get_row( $wpdb->prepare( "SELECT status, next_payment_gmt FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+ $this->assertSame( 'legacy-paused', $stored['status'] );
+ $this->assertSame( '2026-09-15 00:00:00', $stored['next_payment_gmt'] );
+ }
+
+ /**
+ * @testdox An unknown stored cycle status hydrates through every cycle read and survives an update.
+ */
+ public function test_an_unknown_stored_cycle_status_hydrates_and_round_trips(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+ $this->sut->append_cycle( $cycle );
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( $table, array( 'status' => 'legacy-x' ), array( 'id' => $cycle->get_id() ) );
+
+ $head = $this->sut->find_chain_head( $id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->assertSame( 'legacy-x', $head->get_status()->get_value() );
+
+ $history = $this->sut->find_cycle_history( $id );
+ $this->assertCount( 1, $history );
+ $this->assertSame( 'legacy-x', $history[0]->get_status()->get_value() );
+
+ $head->set_reason( 'annotated' );
+ $this->sut->update_cycle( $head );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $stored = $wpdb->get_row( $wpdb->prepare( "SELECT status, reason FROM {$table} WHERE id = %d", $cycle->get_id() ), ARRAY_A );
+ $this->assertSame( 'legacy-x', $stored['status'] );
+ $this->assertSame( 'annotated', $stored['reason'] );
+ }
+
+ /**
+ * @testdox count_by_status keys include extension-registered contract statuses.
+ */
+ public function test_count_by_status_includes_registered_extension_statuses(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+ $this->insert_list_contract( 'paused-by-merchant' );
+
+ $counts = $this->sut->count_by_status();
+
+ $this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
+ $this->assertSame( 1, $counts['paused-by-merchant'] );
+ }
+
/**
* The contract ids of the due scan at `$now`, in scan order.
*
- * @param \DateTimeImmutable $now The cutoff moment.
- * @param int $limit The batch size.
+ * @param \DateTimeImmutable $now The cutoff moment.
+ * @param int $limit The batch size.
+ * @param array<int, string>|null $owners Owners to scan; defaults to the test owner.
* @return array<int, int>
*/
- private function due_ids( \DateTimeImmutable $now, int $limit ): array {
+ private function due_ids( \DateTimeImmutable $now, int $limit, ?array $owners = null ): array {
return array_map(
static function ( RenewalCandidate $candidate ): int {
return $candidate->get_contract_id();
},
- $this->sut->find_due( $now, $limit )
+ $this->sut->find_due( $now, $limit, $owners ?? array( self::OWNER ) )
);
}
@@ -1273,6 +1444,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
* @param string $head_status The head cycle status (a CycleStatus value).
* @param string|null $claimed_until The head cycle lease expiry, or null for none.
* @param string|null $head_ends_at The head period end; defaults to `$next_payment_gmt`.
+ * @param string|null $owner The owning extension slug; defaults to the registered test owner.
*/
private function insert_contract_due_at(
?string $next_payment_gmt,
@@ -1280,7 +1452,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
string $schedule_source = Contract::SCHEDULE_SOURCE_PRIMITIVE,
string $head_status = CycleStatus::BILLED,
?string $claimed_until = null,
- ?string $head_ends_at = null
+ ?string $head_ends_at = null,
+ ?string $owner = self::OWNER
): int {
$contract = Contract::create(
array(
@@ -1292,6 +1465,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'next_payment_gmt' => $next_payment_gmt,
'status' => $status,
'schedule_source' => $schedule_source,
+ 'extension_slug' => $owner,
)
);
$id = $this->sut->insert( $contract );
@@ -1303,7 +1477,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'contract_id' => $id,
'sequence_no' => 1,
'count' => 1,
- 'status' => CycleStatus::from( $head_status ),
+ 'status' => new CycleStatus( $head_status ),
'starts_at_gmt' => '2026-01-15 00:00:00',
'ends_at_gmt' => $head_ends_at ?? $next_payment_gmt,
'expected_total' => '19.99',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
index bb92abb6525..78bd8fa2528 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
@@ -234,20 +234,29 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
}
/**
- * @testdox The due_contract index keys the dispatcher scan as (status, next_payment_gmt).
+ * @testdox The due_owner index keys the dispatcher scan as (extension_slug, next_payment_gmt).
*/
- public function test_contracts_due_contract_index_keys_the_dispatcher_scan(): void {
+ public function test_contracts_due_owner_index_keys_the_dispatcher_scan(): void {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
- // The dispatcher scans status=active AND next_payment_gmt <= now; the status-first
- // column order is load-bearing for the index, so assert it exactly.
- $this->assertContains( 'due_contract', $this->index_names( $table ) );
+ // The dispatcher scans extension_slug IN (registered owners) AND next_payment_gmt <= now;
+ // the owner-first column order is load-bearing for the index, so assert it exactly.
+ // The retired due_contract index is not asserted absent: dbDelta never drops an index,
+ // so it lingers on any reused database until the tables are recreated.
+ $this->assertContains( 'due_owner', $this->index_names( $table ) );
$this->assertSame(
- array( 'status', 'next_payment_gmt' ),
- $this->index_columns( $table, 'due_contract' )
+ array( 'extension_slug', 'next_payment_gmt' ),
+ $this->index_columns( $table, 'due_owner' )
);
}
+ /**
+ * @testdox The schema version is 2.4.0 (owner-scoped due scan).
+ */
+ public function test_schema_version_is_2_4_0(): void {
+ $this->assertSame( '2.4.0', SchemaInstaller::get_version() );
+ }
+
public function test_cycles_table_has_expected_columns(): void {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
index f7eaa2bc833..7082643bf13 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
@@ -1,6 +1,6 @@
<?php
/**
- * Unit tests for the ContractStatus state machine.
+ * Unit tests for the registry-backed ContractStatus helpers.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -9,93 +9,63 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus
*/
class ContractStatusTest extends TestCase {
- public function test_known_statuses_are_valid(): void {
- $this->assertTrue( ContractStatus::is_valid( ContractStatus::ACTIVE ) );
- $this->assertFalse( ContractStatus::is_valid( 'nonsense' ) );
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
}
- public function test_active_can_move_to_hold_and_back(): void {
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::ACTIVE ) );
+ public function test_known_statuses_are_valid(): void {
+ $this->assertTrue( ContractStatus::is_registered( ContractStatus::ACTIVE ) );
+ $this->assertFalse( ContractStatus::is_registered( 'nonsense' ) );
}
- public function test_active_reaches_every_other_status(): void {
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::PENDING_CANCELLATION ) );
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::CANCELLED ) );
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::EXPIRED ) );
+ public function test_defaults_lists_the_five_engine_slugs(): void {
+ $this->assertSame(
+ array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+ ContractStatus::get_defaults()
+ );
}
- public function test_on_hold_cannot_expire(): void {
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION ) );
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::CANCELLED ) );
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::EXPIRED ) );
+ public function test_all_equals_the_defaults_with_nothing_registered(): void {
+ $this->assertSame( ContractStatus::get_defaults(), ContractStatus::get_all() );
}
- public function test_cancelled_and_expired_are_terminal(): void {
- $this->assertTrue( ContractStatus::is_terminal( ContractStatus::CANCELLED ) );
- $this->assertTrue( ContractStatus::is_terminal( ContractStatus::EXPIRED ) );
+ public function test_an_extension_registered_status_is_listed_and_valid(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
- foreach ( ContractStatus::all() as $target ) {
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::CANCELLED, $target ) );
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::EXPIRED, $target ) );
- }
- }
+ $all = ContractStatus::get_all();
- public function test_pending_cancellation_only_reaches_active_or_cancelled(): void {
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::ACTIVE ) );
- $this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED ) );
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::ON_HOLD ) );
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::EXPIRED ) );
+ $this->assertSame( 'paused-by-merchant', end( $all ) );
+ $this->assertTrue( ContractStatus::is_registered( 'paused-by-merchant' ) );
}
- public function test_unknown_statuses_never_transition(): void {
- $this->assertFalse( ContractStatus::is_transition_allowed( 'nonsense', ContractStatus::ACTIVE ) );
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, 'nonsense' ) );
- }
+ public function test_a_cycle_registration_does_not_make_a_contract_status_valid(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
- public function test_same_status_is_not_an_allowed_transition(): void {
- // set_status() short-circuits no-ops; the table itself reports a
- // same-status move as not allowed.
- $this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ACTIVE ) );
+ $this->assertFalse( ContractStatus::is_registered( 'disputed' ) );
}
- public function test_can_transition_aliases_is_transition_allowed(): void {
- $this->assertSame(
- ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ),
- ContractStatus::can_transition( ContractStatus::ACTIVE, ContractStatus::ON_HOLD )
- );
- $this->assertSame(
- ContractStatus::is_transition_allowed( ContractStatus::CANCELLED, ContractStatus::ACTIVE ),
- ContractStatus::can_transition( ContractStatus::CANCELLED, ContractStatus::ACTIVE )
- );
- }
-
- public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
- ContractStatus::assert_transition_allowed( ContractStatus::ACTIVE, ContractStatus::CANCELLED );
-
- // No exception thrown.
- $this->addToAssertionCount( 1 );
- }
-
- public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
- $this->expectException( DomainException::class );
-
- ContractStatus::assert_transition_allowed( ContractStatus::CANCELLED, ContractStatus::ACTIVE );
+ public function test_the_retired_transition_api_is_gone(): void {
+ foreach ( array( 'is_transition_allowed', 'can_transition', 'assert_transition_allowed', 'is_terminal' ) as $method ) {
+ $this->assertFalse( method_exists( ContractStatus::class, $method ), "ContractStatus::{$method}() should not exist." );
+ }
}
- public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
- $this->expectException( DomainException::class );
-
- ContractStatus::assert_transition_allowed( ContractStatus::EXPIRED, ContractStatus::ON_HOLD );
+ public function test_is_valid_checks_the_slug_format_only(): void {
+ $this->assertTrue( ContractStatus::is_valid( ContractStatus::PENDING_CANCELLATION ) );
+ $this->assertTrue( ContractStatus::is_valid( 'legacy-x' ), 'Well-formed but unregistered.' );
+ $this->assertFalse( ContractStatus::is_registered( 'legacy-x' ) );
+ $this->assertFalse( ContractStatus::is_valid( 'On Hold' ) );
+ $this->assertFalse( ContractStatus::is_valid( "active\n" ) );
+ $this->assertFalse( ContractStatus::is_valid( str_repeat( 'a', 21 ) ) );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php
deleted file mode 100644
index 8c8cbd3261d..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-/**
- * Unit tests for status-transition enforcement on the Contract entity.
- *
- * Confirms that Contract::set_status() routes every status change through the
- * ContractStatus state machine, so illegal moves are rejected at the entity
- * boundary rather than being persisted.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
-
-use DomainException;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract::set_status
- */
-class ContractStatusTransitionTest extends TestCase {
-
- /**
- * Build a minimal active contract for transition tests.
- *
- * @param string $status Starting status.
- */
- private function make_contract( string $status = ContractStatus::ACTIVE ): Contract {
- return Contract::create(
- array(
- 'customer_id' => 1,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'origin_order_id' => 1,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'status' => $status,
- 'billing_total' => '10.00',
- 'schedule_source' => Contract::SCHEDULE_SOURCE_PRIMITIVE,
- 'items' => array(),
- 'addresses' => array(),
- 'meta' => array(),
- )
- );
- }
-
- public function test_legal_transition_is_applied(): void {
- $contract = $this->make_contract( ContractStatus::ACTIVE );
-
- $contract->set_status( ContractStatus::ON_HOLD );
-
- $this->assertSame( ContractStatus::ON_HOLD, $contract->get_status() );
- }
-
- public function test_same_status_is_a_noop(): void {
- $contract = $this->make_contract( ContractStatus::ACTIVE );
-
- // Active is terminal-free but cannot transition to itself in the table;
- // set_status() must treat the no-op as a no-op, not an exception.
- $contract->set_status( ContractStatus::ACTIVE );
-
- $this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
- }
-
- public function test_illegal_transition_throws_and_leaves_status_unchanged(): void {
- $contract = $this->make_contract( ContractStatus::CANCELLED );
-
- try {
- $contract->set_status( ContractStatus::ACTIVE );
- $this->fail( 'Expected a DomainException for a transition out of a terminal status.' );
- } catch ( DomainException $e ) {
- $this->assertSame( ContractStatus::CANCELLED, $contract->get_status() );
- }
- }
-
- public function test_pending_cancellation_to_on_hold_is_rejected(): void {
- $contract = $this->make_contract( ContractStatus::PENDING_CANCELLATION );
-
- $this->expectException( DomainException::class );
-
- $contract->set_status( ContractStatus::ON_HOLD );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
index 93c24a1c0ef..d04957c122f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -19,6 +19,7 @@ use DomainException;
use PHPUnit\Framework\TestCase;
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\ValueObject\InstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -27,6 +28,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
*/
class ContractTest extends TestCase {
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
+ }
+
/**
* A complete, valid contract row.
*
@@ -401,4 +407,114 @@ class ContractTest extends TestCase {
$this->assertArrayNotHasKey( 'plan_snapshot', $contract->to_storage(), 'plan_snapshot is a hydrated read-only field, not a stored column.' );
}
+
+ /**
+ * Hydrate a stored contract row with the given status.
+ *
+ * @param string $status Stored status.
+ */
+ private function stored_contract( string $status ): Contract {
+ $row = $this->valid_row();
+ $row['status'] = $status;
+
+ return Contract::from_storage( $row );
+ }
+
+ /**
+ * @testdox set_status() moves between any two registered statuses (no transition table).
+ */
+ public function test_set_status_moves_between_any_registered_statuses(): void {
+ $cancelled = $this->stored_contract( ContractStatus::CANCELLED );
+ $cancelled->set_status( ContractStatus::ACTIVE );
+ $this->assertSame( ContractStatus::ACTIVE, $cancelled->get_status() );
+
+ $pending_cancellation = $this->stored_contract( ContractStatus::PENDING_CANCELLATION );
+ $pending_cancellation->set_status( ContractStatus::ON_HOLD );
+ $this->assertSame( ContractStatus::ON_HOLD, $pending_cancellation->get_status() );
+ }
+
+ /**
+ * @testdox set_status() with the current status is a no-op.
+ */
+ public function test_set_status_to_the_same_status_is_a_no_op(): void {
+ $contract = $this->make_contract();
+
+ $contract->set_status( ContractStatus::ACTIVE );
+
+ $this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+ }
+
+ /**
+ * @testdox set_status() rejects an unregistered status and leaves the status unchanged.
+ */
+ public function test_set_status_rejects_an_unregistered_status(): void {
+ $contract = $this->make_contract();
+
+ try {
+ $contract->set_status( 'never-registered' );
+ $this->fail( 'Expected a DomainException for an unregistered status.' );
+ } catch ( DomainException $e ) {
+ $this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+ }
+ }
+
+ /**
+ * @testdox An extension-registered status is accepted by create() and set_status().
+ */
+ public function test_an_extension_registered_status_is_writable(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+ $created = Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'status' => 'paused-by-merchant',
+ )
+ );
+ $this->assertSame( 'paused-by-merchant', $created->get_status() );
+
+ $contract = $this->make_contract();
+ $contract->set_status( 'paused-by-merchant' );
+ $this->assertSame( 'paused-by-merchant', $contract->get_status() );
+ }
+
+ /**
+ * @testdox from_storage() hydrates an unregistered stored status and to_storage() returns it verbatim.
+ */
+ public function test_an_unregistered_stored_status_round_trips(): void {
+ $contract = $this->stored_contract( 'legacy-paused' );
+
+ $this->assertSame( 'legacy-paused', $contract->get_status() );
+ $this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
+
+ $contract->set_next_payment_gmt( '2026-03-01 00:00:00' );
+ // Setting the same unregistered value is a no-op, not a registration failure.
+ $contract->set_status( 'legacy-paused' );
+
+ $this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
+ }
+
+ /**
+ * @testdox set_meta() adds, overwrites, and (with null) removes a key.
+ */
+ public function test_set_meta_adds_overwrites_and_removes_a_key(): void {
+ $contract = Contract::from_storage( $this->valid_row(), null, array(), array(), array( 'keep' => 'me' ) );
+
+ $contract->set_meta( 'k', 'v' );
+ $this->assertSame(
+ array(
+ 'keep' => 'me',
+ 'k' => 'v',
+ ),
+ $contract->get_meta()
+ );
+
+ $contract->set_meta( 'k', 'w' );
+ $this->assertSame( 'w', $contract->get_meta()['k'] );
+
+ $contract->set_meta( 'k', null );
+ $this->assertSame( array( 'keep' => 'me' ), $contract->get_meta() );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
index 87ba8a67499..95d28f19b7d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
@@ -1,6 +1,6 @@
<?php
/**
- * Unit tests for the CycleStatus state machine.
+ * Unit tests for the registry-backed CycleStatus value object.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -12,13 +12,19 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus
*/
class CycleStatusTest extends TestCase {
- public function test_all_returns_exactly_the_known_statuses(): void {
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
+ }
+
+ public function test_get_all_returns_exactly_the_known_statuses(): void {
$this->assertSame(
array(
CycleStatus::PENDING,
@@ -27,50 +33,22 @@ class CycleStatusTest extends TestCase {
CycleStatus::FAILED,
CycleStatus::CANCELLED,
),
- CycleStatus::all()
+ CycleStatus::get_all()
);
}
public function test_known_statuses_are_valid(): void {
- $this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
- $this->assertTrue( CycleStatus::is_valid( CycleStatus::PROCESSING ) );
- $this->assertTrue( CycleStatus::is_valid( CycleStatus::BILLED ) );
- $this->assertTrue( CycleStatus::is_valid( CycleStatus::FAILED ) );
- $this->assertTrue( CycleStatus::is_valid( CycleStatus::CANCELLED ) );
- $this->assertFalse( CycleStatus::is_valid( 'nonsense' ) );
- }
-
- public function test_pending_reaches_processing_billed_failed_and_cancelled(): void {
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PROCESSING ) );
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ) );
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::FAILED ) );
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::CANCELLED ) );
- }
-
- public function test_processing_settles_to_billed_failed_or_cancelled_but_not_back_to_pending(): void {
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::BILLED ) );
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::FAILED ) );
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::CANCELLED ) );
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::PENDING ) );
- $this->assertFalse( CycleStatus::is_terminal( CycleStatus::PROCESSING ) );
- }
-
- public function test_failed_can_be_retried_to_pending_or_cancelled(): void {
- // A failed cycle can be retried (re-queued to pending by an admin trigger) or cancelled,
- // but not settled directly to billed without re-attempting the charge.
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::PENDING ) );
- $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::CANCELLED ) );
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED ) );
+ $this->assertTrue( CycleStatus::is_registered( CycleStatus::PENDING ) );
+ $this->assertTrue( CycleStatus::is_registered( CycleStatus::PROCESSING ) );
+ $this->assertTrue( CycleStatus::is_registered( CycleStatus::BILLED ) );
+ $this->assertTrue( CycleStatus::is_registered( CycleStatus::FAILED ) );
+ $this->assertTrue( CycleStatus::is_registered( CycleStatus::CANCELLED ) );
+ $this->assertFalse( CycleStatus::is_registered( 'nonsense' ) );
}
public function test_billed_and_cancelled_are_terminal(): void {
$this->assertTrue( CycleStatus::is_terminal( CycleStatus::BILLED ) );
$this->assertTrue( CycleStatus::is_terminal( CycleStatus::CANCELLED ) );
-
- foreach ( CycleStatus::all() as $target ) {
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::BILLED, $target ) );
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::CANCELLED, $target ) );
- }
}
public function test_pending_processing_and_failed_are_not_terminal(): void {
@@ -79,82 +57,83 @@ class CycleStatusTest extends TestCase {
$this->assertFalse( CycleStatus::is_terminal( CycleStatus::FAILED ) );
}
- public function test_unknown_statuses_never_transition(): void {
- $this->assertFalse( CycleStatus::is_transition_allowed( 'nonsense', CycleStatus::BILLED ) );
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, 'nonsense' ) );
- }
+ public function test_unknown_and_extension_registered_statuses_are_not_terminal(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
- public function test_same_status_is_not_an_allowed_transition(): void {
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PENDING ) );
+ $this->assertFalse( CycleStatus::is_terminal( 'disputed' ) );
+ $this->assertFalse( CycleStatus::is_terminal( 'legacy-x' ) );
}
- public function test_can_transition_aliases_is_transition_allowed(): void {
- $this->assertSame(
- CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ),
- CycleStatus::can_transition( CycleStatus::PENDING, CycleStatus::BILLED )
- );
- $this->assertSame(
- CycleStatus::is_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING ),
- CycleStatus::can_transition( CycleStatus::BILLED, CycleStatus::PENDING )
- );
+ public function test_constructor_wraps_a_default_status(): void {
+ $this->assertSame( CycleStatus::PENDING, ( new CycleStatus( CycleStatus::PENDING ) )->get_value() );
}
- public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
- CycleStatus::assert_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED );
-
- // No exception thrown.
- $this->addToAssertionCount( 1 );
- }
-
- public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
- $this->expectException( DomainException::class );
-
- CycleStatus::assert_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED );
+ /**
+ * Registration is checked where a cycle status is written, not when the value is built,
+ * so a stored status an unregistered extension wrote can still be loaded.
+ */
+ public function test_constructor_wraps_a_well_formed_unregistered_status(): void {
+ $this->assertSame( 'legacy-x', ( new CycleStatus( 'legacy-x' ) )->get_value() );
}
- public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
+ /**
+ * @dataProvider malformed_slugs
+ *
+ * @param string $slug Malformed slug.
+ */
+ public function test_constructor_rejects_a_malformed_slug( string $slug ): void {
$this->expectException( DomainException::class );
- CycleStatus::assert_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING );
+ new CycleStatus( $slug );
+ }
+
+ /**
+ * Strings that are not well-formed status slugs.
+ *
+ * @return array<string, array{0: string}>
+ */
+ public function malformed_slugs(): array {
+ return array(
+ 'empty' => array( '' ),
+ 'uppercase' => array( 'Pending' ),
+ 'double hyphen' => array( 'on--hold' ),
+ 'trailing newline' => array( "pending\n" ),
+ 'markup' => array( '<b>x</b>' ),
+ 'over 20 chars' => array( str_repeat( 'a', 21 ) ),
+ );
}
- public function test_named_factories_carry_their_status_value(): void {
- $this->assertSame( CycleStatus::PENDING, CycleStatus::pending()->get_value() );
- $this->assertSame( CycleStatus::PROCESSING, CycleStatus::processing()->get_value() );
- $this->assertSame( CycleStatus::BILLED, CycleStatus::billed()->get_value() );
- $this->assertSame( CycleStatus::FAILED, CycleStatus::failed()->get_value() );
- $this->assertSame( CycleStatus::CANCELLED, CycleStatus::cancelled()->get_value() );
+ public function test_is_valid_checks_the_slug_format_only(): void {
+ $this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
+ $this->assertTrue( CycleStatus::is_valid( 'legacy-x' ), 'Well-formed but unregistered.' );
+ $this->assertFalse( CycleStatus::is_registered( 'legacy-x' ) );
}
- public function test_from_builds_a_known_status(): void {
- $this->assertSame( CycleStatus::PENDING, CycleStatus::from( CycleStatus::PENDING )->get_value() );
+ /**
+ * @dataProvider malformed_slugs
+ *
+ * @param string $slug Malformed slug.
+ */
+ public function test_is_valid_rejects_a_malformed_slug( string $slug ): void {
+ $this->assertFalse( CycleStatus::is_valid( $slug ) );
}
- public function test_from_rejects_an_unknown_status(): void {
- $this->expectException( DomainException::class );
+ public function test_is_registered_is_scoped_to_cycle_registrations(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'disputed' );
+ $this->assertFalse( CycleStatus::is_registered( 'disputed' ) );
- CycleStatus::from( 'nonsense' );
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+ $this->assertTrue( CycleStatus::is_registered( 'disputed' ) );
}
public function test_equals_compares_by_value(): void {
- $this->assertTrue( CycleStatus::pending()->equals( CycleStatus::pending() ) );
- $this->assertFalse( CycleStatus::pending()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( ( new CycleStatus( CycleStatus::PENDING ) )->equals( new CycleStatus( CycleStatus::PENDING ) ) );
+ $this->assertFalse( ( new CycleStatus( CycleStatus::PENDING ) )->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
- public function test_can_transition_to_mirrors_the_static_table(): void {
- $this->assertTrue( CycleStatus::pending()->can_transition_to( CycleStatus::billed() ) );
- $this->assertFalse( CycleStatus::failed()->can_transition_to( CycleStatus::billed() ) );
- }
-
- public function test_transition_to_returns_the_target_for_a_legal_move(): void {
- $next = CycleStatus::pending()->transition_to( CycleStatus::billed() );
-
- $this->assertTrue( $next->equals( CycleStatus::billed() ) );
- }
-
- public function test_transition_to_throws_for_an_illegal_move(): void {
- $this->expectException( DomainException::class );
-
- CycleStatus::failed()->transition_to( CycleStatus::billed() );
+ public function test_the_retired_transition_api_and_factories_are_gone(): void {
+ foreach ( array( 'is_transition_allowed', 'can_transition', 'assert_transition_allowed', 'can_transition_to', 'transition_to', 'from', 'stored', 'pending', 'processing', 'billed', 'failed', 'cancelled', 'all', 'defaults' ) as $method ) {
+ $this->assertFalse( method_exists( CycleStatus::class, $method ), "CycleStatus::{$method}() should not exist." );
+ }
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
index e695a67e070..8f6480c7d73 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -13,6 +13,7 @@ use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
@@ -21,6 +22,11 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
*/
class CycleTest extends TestCase {
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
+ }
+
/**
* Build a pending billing cycle with sensible defaults, overridable per test.
*
@@ -51,7 +57,7 @@ class CycleTest extends TestCase {
$this->assertSame( 1, $cycle->get_sequence_no() );
$this->assertSame( 1, $cycle->get_count() );
$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
$this->assertNull( $cycle->get_reason() );
$this->assertSame( '2026-02-01 00:00:00', $cycle->get_starts_at_gmt() );
$this->assertSame( '2026-03-01 00:00:00', $cycle->get_ends_at_gmt() );
@@ -74,9 +80,9 @@ class CycleTest extends TestCase {
public function test_create_can_build_a_billed_cycle_directly(): void {
// The checkout signup cycle is created directly billed (the origin order is paid).
- $cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+ $cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
}
public function test_create_requires_a_contract_id(): void {
@@ -169,24 +175,93 @@ class CycleTest extends TestCase {
public function test_status_changes_go_through_cycle_status(): void {
$cycle = $this->make_pending();
- $cycle->set_status( CycleStatus::billed() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $cycle->set_status( new CycleStatus( CycleStatus::BILLED ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
+ }
+
+ public function test_status_change_is_not_constrained_by_a_transition_table(): void {
+ $cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );
+
+ $cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
+
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::FAILED ) ) );
+ }
+
+ public function test_create_accepts_an_extension_registered_status_string(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+
+ $cycle = $this->make_pending( array( 'status' => 'disputed' ) );
+
+ $this->assertSame( 'disputed', $cycle->get_status()->get_value() );
+ }
+
+ public function test_from_storage_hydrates_an_unregistered_stored_status(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 5,
+ 'contract_id' => 7,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'kind' => Cycle::KIND_BILLING,
+ 'status' => 'legacy-x',
+ 'starts_at_gmt' => '2026-03-01 00:00:00',
+ 'ends_at_gmt' => '2026-04-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ )
+ );
+
+ $this->assertSame( 'legacy-x', $cycle->get_status()->get_value() );
+ $this->assertSame( 'legacy-x', $cycle->to_storage()['status'] );
}
- public function test_status_change_rejects_an_illegal_transition(): void {
- $cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+ public function test_set_status_rejects_an_unregistered_status_and_leaves_the_status_unchanged(): void {
+ $cycle = $this->make_pending();
+
+ try {
+ $cycle->set_status( new CycleStatus( 'nope' ) );
+ $this->fail( 'Expected a DomainException for an unregistered status.' );
+ } catch ( DomainException $e ) {
+ $this->assertStringContainsString( 'nope', $e->getMessage() );
+ }
+
+ $this->assertSame( CycleStatus::PENDING, $cycle->get_status()->get_value() );
+ }
- // billed is terminal, so it cannot move to failed.
+ public function test_create_rejects_an_unregistered_status_instance(): void {
$this->expectException( DomainException::class );
- $cycle->set_status( CycleStatus::failed() );
+
+ $this->make_pending( array( 'status' => new CycleStatus( 'nope' ) ) );
+ }
+
+ public function test_a_hydrated_unregistered_status_can_be_kept_unchanged(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 5,
+ 'contract_id' => 7,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'kind' => Cycle::KIND_BILLING,
+ 'status' => 'legacy-x',
+ 'starts_at_gmt' => '2026-03-01 00:00:00',
+ 'ends_at_gmt' => '2026-04-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ )
+ );
+
+ $cycle->set_status( new CycleStatus( 'legacy-x' ) );
+ $cycle->set_reason( 'annotated' );
+
+ $this->assertSame( 'legacy-x', $cycle->to_storage()['status'] );
}
public function test_setting_the_same_status_is_a_no_op(): void {
$cycle = $this->make_pending();
- $cycle->set_status( CycleStatus::pending() );
+ $cycle->set_status( new CycleStatus( CycleStatus::PENDING ) );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
}
public function test_period_boundaries_are_frozen_at_construction(): void {
@@ -259,7 +334,7 @@ class CycleTest extends TestCase {
public function test_reason_can_be_annotated_when_cancelling_a_pending_cycle(): void {
$cycle = $this->make_pending();
- $cycle->set_status( CycleStatus::cancelled() );
+ $cycle->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
$cycle->set_reason( 'customer requested cancellation' );
$this->assertSame( 'customer requested cancellation', $cycle->get_reason() );
@@ -267,7 +342,7 @@ class CycleTest extends TestCase {
public function test_reason_can_be_annotated_on_a_billed_cycle(): void {
// `reason` is one of the few mutable fields: any cycle may carry one.
- $cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+ $cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );
$cycle->set_reason( 'settled after retry' );
@@ -276,7 +351,7 @@ class CycleTest extends TestCase {
public function test_reason_can_be_annotated_on_a_failed_cycle(): void {
$cycle = $this->make_pending();
- $cycle->set_status( CycleStatus::failed() );
+ $cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
$cycle->set_reason( 'gateway declined the charge' );
@@ -308,7 +383,7 @@ class CycleTest extends TestCase {
$this->assertSame( 7, $cycle->get_contract_id() );
$this->assertSame( 2, $cycle->get_sequence_no() );
$this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
$this->assertSame( 11, $cycle->get_plan_snapshot_id() );
$this->assertSame( 22, $cycle->get_items_snapshot_id() );
$this->assertSame( 123, $cycle->get_order_id() );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
new file mode 100644
index 00000000000..ac3a19aa865
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Unit tests for the WordPress-free StatusRegistry.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use InvalidArgumentException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry
+ */
+class StatusRegistryTest extends TestCase {
+
+ protected function tearDown(): void {
+ StatusRegistry::reset();
+ parent::tearDown();
+ }
+
+ public function test_all_returns_exactly_the_engine_defaults_with_nothing_registered(): void {
+ $this->assertSame(
+ array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+ StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
+ );
+ $this->assertSame(
+ array( 'pending', 'processing', 'billed', 'failed', 'cancelled' ),
+ StatusRegistry::get_all( StatusRegistry::KIND_CYCLE )
+ );
+ }
+
+ public function test_registered_statuses_append_after_the_defaults_in_registration_order(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'awaiting-shipment' );
+
+ $this->assertSame(
+ array_merge( ContractStatus::get_defaults(), array( 'paused-by-merchant', 'awaiting-shipment' ) ),
+ StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
+ );
+ }
+
+ public function test_is_registered_covers_defaults_and_registrations_only(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+
+ $this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, CycleStatus::BILLED ) );
+ $this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'disputed' ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'never-registered' ) );
+ }
+
+ public function test_re_registering_a_default_or_a_registered_slug_is_a_no_op(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, ContractStatus::ACTIVE );
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+ $this->assertSame(
+ array_merge( ContractStatus::get_defaults(), array( 'paused-by-merchant' ) ),
+ StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
+ );
+ }
+
+ public function test_kinds_are_independent(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+ $this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CYCLE, 'paused-by-merchant' ) );
+ $this->assertSame( CycleStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CYCLE ) );
+ }
+
+ /**
+ * @dataProvider provide_invalid_slugs
+ *
+ * @param string $slug Invalid slug.
+ */
+ public function test_register_rejects_an_invalid_slug( string $slug ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, $slug );
+ }
+
+ /**
+ * @dataProvider provide_invalid_slugs
+ *
+ * @param string $slug Invalid slug.
+ */
+ public function test_is_valid_slug_rejects_an_invalid_slug( string $slug ): void {
+ $this->assertFalse( StatusRegistry::is_valid_slug( $slug ) );
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_invalid_slugs(): array {
+ return array(
+ 'empty' => array( '' ),
+ 'uppercase' => array( 'Active' ),
+ 'underscore' => array( 'on_hold' ),
+ 'leading hyphen' => array( '-x' ),
+ 'trailing hyphen' => array( 'x-' ),
+ 'double hyphen' => array( 'a--b' ),
+ 'space' => array( 'with space' ),
+ 'markup' => array( '<b>' ),
+ 'over 20 chars' => array( str_repeat( 'a', 21 ) ),
+ 'trailing LF' => array( "paused\n" ),
+ 'trailing CRLF' => array( "paused\r\n" ),
+ );
+ }
+
+ /**
+ * @dataProvider provide_valid_edge_slugs
+ *
+ * @param string $slug Valid slug.
+ */
+ public function test_register_accepts_a_valid_edge_case_slug( string $slug ): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, $slug );
+
+ $this->assertTrue( StatusRegistry::is_valid_slug( $slug ) );
+ $this->assertTrue( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, $slug ) );
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_valid_edge_slugs(): array {
+ return array(
+ 'single char' => array( 'a' ),
+ 'digits and hyphen' => array( 'a1-b2' ),
+ 'exactly 20 chars' => array( str_repeat( 'a', 20 ) ),
+ );
+ }
+
+ public function test_register_rejects_an_unknown_kind(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ StatusRegistry::register( 'plan', 'draft' );
+ }
+
+ public function test_all_rejects_an_unknown_kind(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ StatusRegistry::get_all( 'plan' );
+ }
+
+ public function test_is_registered_rejects_an_unknown_kind(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ StatusRegistry::is_registered( 'plan', 'draft' );
+ }
+
+ public function test_reset_clears_registrations_but_keeps_the_defaults(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+ StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
+
+ StatusRegistry::reset();
+
+ $this->assertSame( ContractStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT ) );
+ $this->assertSame( CycleStatus::get_defaults(), StatusRegistry::get_all( StatusRegistry::KIND_CYCLE ) );
+ $this->assertFalse( StatusRegistry::is_registered( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' ) );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
index 46fb9fe8dbe..62fc5f3e865 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
@@ -146,7 +146,7 @@ class RenewalCalculatorTest extends TestCase {
)
);
- $this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
$this->assertSame( 7, $cycle->get_contract_id() );
$this->assertSame( 5, $cycle->get_count() );
$this->assertSame( 5, $cycle->get_sequence_no() );