Commit ef6d8dbd10f for woocommerce
commit ef6d8dbd10f911acd92198f479a93c9510805528
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Thu Oct 15 10:34:41 2026 +0200
Move renewals out of the subscriptions engine (#69758)
Move renewals out of the subscriptions engine
- Notify a contract's owning extension through a registered due handler when its next payment passes, with a claim so overlapping scans notify once
- Add cycle tools to Api\Contracts: claimed appends, claim_cycle(), transition_cycle() and lookups by order
- Add Api\Payments: the subscriptions_engine gateway feature, a scheduled payment action and the reusable payment method query
- Remove the engine's renewal flow, renewal order building, Api\Subscriptions and the gateway capability registry
- Bump the schema to 2.7.0 with a contract claim column and an open schedule_source slug
diff --git a/packages/php/woocommerce-subscriptions-engine/README.md b/packages/php/woocommerce-subscriptions-engine/README.md
index b968921ef3a..9a93aa51ef3 100644
--- a/packages/php/woocommerce-subscriptions-engine/README.md
+++ b/packages/php/woocommerce-subscriptions-engine/README.md
@@ -1,3 +1,9 @@
# WooCommerce Subscriptions Engine
This package implements a general-purpose subscriptions engine for WooCommerce.
+
+The engine records subscription facts (plans, contracts, billing cycles) and offers tools to work with them; extensions own every flow and decision. In particular:
+
+- **Due dispatch:** an extension registers a contract due handler with its slug, and the engine calls it when one of its contracts reaches its next due moment. What happens then (renew, skip, clear the due moment) is the extension's decision.
+- **Cycle tools:** `Api\Contracts` offers claimed cycle appends, cycle claims and compare-and-set status transitions, so an extension can run its billing flow safely under concurrency.
+- **Payments broker:** `Api\Payments` lets an extension ask the order's gateway to take a scheduled payment off-session (`Payments::process_scheduled_payment( $order )`). Gateways opt in by adding the `subscriptions_engine` feature to their `$supports` and charging the order on `woocommerce_subscriptions_engine_scheduled_payment_{gateway_id}( $order )`, reporting the outcome through the order status. What a gateway can do beyond that (renewal payments, payment method changes, ...) it declares with the existing WooCommerce gateway features; extensions check the ones an operation needs with `Payments::gateway_supports( $gateway_id, $features )`, and the engine does not interpret them.
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-renewal-inversion b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-renewal-inversion
new file mode 100644
index 00000000000..179cf505968
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-renewal-inversion
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move renewals to extensions and add due dispatch, cycle tools and a payments broker in the unreleased subscriptions engine; no changelog entry needed.
+
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
index 5eba92ee3c3..4e53c422c55 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -3,11 +3,11 @@
* Contracts - the engine's public contract facade (reads and writes).
*
* Extensions create and progressively build contracts from explicit argument arrays:
- * the engine records the facts it is given and decides nothing about them. Any caller
- * may read or write any contract (authorization is the caller's concern). Reads return
- * read-only views ({@see ContractView}, {@see CycleView}). The engine opens no
- * transaction and keeps no cache, so a caller may wrap several calls in its own
- * transaction. No hooks fire.
+ * the engine records the facts it is given and decides nothing about them. Cycle tools
+ * (claimed appends, cycle claims, compare-and-set transitions) let an extension run its own
+ * billing flow safely under concurrency. Any caller may read or write any contract
+ * (authorization is the caller's concern). Reads return read-only views ({@see ContractView},
+ * {@see CycleView}). The engine opens no transaction and keeps no cache. No hooks fire.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api
*/
@@ -87,6 +87,18 @@ final class Contracts {
'expected_total' => true,
'currency' => true,
'order_id' => true,
+ 'claim_seconds' => true,
+ );
+
+ /**
+ * Keys accepted by {@see self::transition_cycle()}, as a key map.
+ *
+ * @var array<string, true>
+ */
+ private const TRANSITION_KEYS = array(
+ 'order_id' => true,
+ 'reason' => true,
+ 'claim_seconds' => true,
);
/**
@@ -95,10 +107,14 @@ final class Contracts {
* Only `extension_slug` is required; the status defaults to `draft`. Dates accept a
* `DateTimeInterface` or a GMT `Y-m-d H:i:s` string; money accepts numbers or numeric
* strings, and a non-null money value requires a currency. `items` is a list of item
- * rows; `addresses` is keyed `billing` / `shipping`. Unknown keys, also inside item rows
- * and addresses, raise a `_doing_it_wrong()` notice and are ignored. The contract row,
- * items and addresses are separate writes and the engine opens no transaction: wrap the
- * call in one when a failed write must leave nothing behind.
+ * rows; `addresses` is keyed `billing` / `shipping`. `schedule_source` says who keeps the
+ * payment schedule: `subscriptions_engine` (the default; the engine notifies the owner when
+ * `next_payment_gmt` passes) or the slug (1-64 of `a-z0-9_-`) of a gateway or extension that
+ * keeps its own schedule and triggers its own renewals; the engine never dispatches those
+ * contracts. Unknown keys, also inside item rows and addresses, raise a `_doing_it_wrong()`
+ * notice and are ignored. The contract row, items and addresses are separate writes and the
+ * engine opens no transaction: wrap the call in one when a failed write must leave nothing
+ * behind.
*
* @param array<string, mixed> $args Contract fields: `extension_slug` (required, the owning
* extension), `status` (a registered contract status,
@@ -178,17 +194,24 @@ final class Contracts {
* `status` (a registered cycle status) defaults to `pending`; `kind` defaults to
* `billing`; an absent or null `sequence_no` is assigned on append as the head's plus one;
* `count` is the caller's chargeable number (absent or null for a non-counting cycle;
- * unique within the chain); `expected_total` defaults to 0; `order_id` is optional. The contract is not
- * read: appending to an unknown contract id is a caller error. Unknown keys raise a
- * `_doing_it_wrong()` notice and are ignored.
+ * unique within the chain); `expected_total` defaults to 0; `order_id` is optional.
+ * `claim_seconds` (positive int) appends the cycle claimed: the caller holds its claim for
+ * that long, measured on the database clock like {@see self::claim_cycle()}. The contract is not read: appending to an
+ * unknown contract id is a caller error. Unknown keys raise a `_doing_it_wrong()` notice
+ * and are ignored.
+ *
+ * Losing the append race is an outcome, not an error: when the chain position or count is
+ * already taken (another caller appended it first), nothing is written and the call returns
+ * null, as {@see self::claim_cycle()} and {@see self::transition_cycle()} return false when
+ * their compare-and-set loses. A storage failure, such as a value longer than its column
+ * (`kind`), throws a `RuntimeException` and writes nothing.
*
* @param int $contract_id Contract id.
* @param array<string, mixed> $args Cycle fields.
- * @return CycleView The appended cycle.
+ * @return CycleView|null The appended cycle, or null when the position or count is already taken.
* @throws InvalidArgumentException If a required key is missing or a value is invalid.
- * @throws DomainException If the chain position or count is already taken.
*/
- public static function add_cycle( int $contract_id, array $args ): CycleView {
+ public static function add_cycle( int $contract_id, array $args ): ?CycleView {
$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );
$cycle_args = array(
@@ -204,6 +227,10 @@ final class Contracts {
'order_id' => ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
);
+ $claim_seconds = array_key_exists( 'claim_seconds', $filtered_args )
+ ? ArgumentValidator::validate_positive_int( 'claim_seconds', $filtered_args['claim_seconds'] )
+ : null;
+
try {
$cycle = Cycle::create( $cycle_args );
} catch ( DomainException $e ) {
@@ -211,14 +238,79 @@ final class Contracts {
}
try {
- ( new ContractRepository() )->append_cycle( $cycle, null );
+ ( new ContractRepository() )->append_cycle( $cycle, $claim_seconds );
} catch ( DuplicateCycleException $e ) {
- throw new DomainException( 'Contracts: the cycle position or count already exists.' );
+ return null;
}
return CycleView::from_cycle( $cycle );
}
+ /**
+ * Take the claim on a cycle still in `$status` whose claim expired or was never set. A
+ * compare-and-set on the status and the claim: of concurrent callers that read `$status`,
+ * exactly one gets true and holds the cycle for `$claim_seconds`; the others get false.
+ * Claims are measured on the database clock.
+ *
+ * @param int $cycle_id Cycle id.
+ * @param string $status The status the caller read; the cycle must still carry it.
+ * @param int $claim_seconds Claim length in seconds (positive).
+ * @return bool True when the caller now holds the cycle.
+ * @throws InvalidArgumentException If `$claim_seconds` is not positive.
+ */
+ public static function claim_cycle( int $cycle_id, string $status, int $claim_seconds ): bool {
+ return ( new ContractRepository() )->claim_cycle( $cycle_id, $status, ArgumentValidator::validate_positive_int( 'claim_seconds', $claim_seconds ) );
+ }
+
+ /**
+ * Move a cycle from `$from_status` to `$to_status` as a compare-and-set on its status: of
+ * concurrent callers that read `$from_status`, exactly one gets true. `$args`: `order_id`
+ * (?int) and `reason` (?string) are written when present (omitted keeps the stored value,
+ * null clears it); `claim_seconds` (int) holds the cycle for that long, and without it the
+ * claim is released. Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+ * A same-status call (`$from_status === $to_status`) only writes `$args`, so it returns
+ * true whenever the cycle still carries the status, even when the values were already stored.
+ *
+ * @param int $cycle_id Cycle id.
+ * @param string $from_status The status the caller read.
+ * @param string $to_status The status to write; a registered cycle status.
+ * @param array<string, mixed> $args Optional `order_id`, `reason`, `claim_seconds`.
+ * @return bool True when this call made the transition, or for a same-status call when the
+ * cycle carries the status.
+ * @throws InvalidArgumentException If `$to_status` is not registered or a value is invalid.
+ */
+ public static function transition_cycle( int $cycle_id, string $from_status, string $to_status, array $args = array() ): bool {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::TRANSITION_KEYS );
+ $changes = array();
+
+ if ( array_key_exists( 'order_id', $filtered_args ) ) {
+ $changes['order_id'] = ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] );
+ }
+ if ( array_key_exists( 'reason', $filtered_args ) ) {
+ $changes['reason'] = ArgumentValidator::validate_nullable_string( 'reason', $filtered_args['reason'] );
+ }
+ if ( array_key_exists( 'claim_seconds', $filtered_args ) ) {
+ $changes['claim_seconds'] = ArgumentValidator::validate_positive_int( 'claim_seconds', $filtered_args['claim_seconds'] );
+ }
+
+ try {
+ return ( new ContractRepository() )->transition_cycle( $cycle_id, $from_status, $to_status, $changes );
+ } catch ( DomainException $e ) {
+ throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the repository message is not output.
+ }
+ }
+
+ /**
+ * The cycles linked to an order, newest first, across contracts and chain kinds (one
+ * order may serve several cycles).
+ *
+ * @param int $order_id Order id.
+ * @return array<int, CycleView>
+ */
+ public static function find_cycles_by_order( int $order_id ): array {
+ return self::cycle_views( ( new ContractRepository() )->find_cycles_by_order_id( $order_id ) );
+ }
+
/**
* Add a meta value to a contract, like `add_post_meta()`. A key may hold several values.
* The contract is not looked up: meta for an unknown contract id is a caller error.
@@ -414,19 +506,19 @@ final class Contracts {
}
/**
- * Fetch a window of the contract's billing cycles, newest first.
+ * Fetch a window of the contract's billing cycles, newest first. A negative `$limit` or
+ * `$offset` throws an InvalidArgumentException.
*
* @param int $contract_id Contract id.
* @param int $limit Maximum cycles to return.
+ * @param int $offset Cycles to skip (for paging).
* @return array<int, CycleView> Cycles newest first.
*/
- public static function get_cycles( int $contract_id, int $limit = 20 ): array {
- return array_map(
- static function ( Cycle $cycle ): CycleView {
- return CycleView::from_cycle( $cycle );
- },
- ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit )
- );
+ public static function get_cycles( int $contract_id, int $limit = 20, int $offset = 0 ): array {
+ $limit = ArgumentValidator::validate_non_negative_int( 'limit', $limit );
+ $offset = ArgumentValidator::validate_non_negative_int( 'offset', $offset );
+
+ return self::cycle_views( ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit, $offset ) );
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
@@ -529,6 +621,21 @@ final class Contracts {
return null === $contract ? null : ContractView::from_contract( $contract, $with_children );
}
+ /**
+ * Views of cycles.
+ *
+ * @param array<int, Cycle> $cycles Cycles.
+ * @return array<int, CycleView>
+ */
+ private static function cycle_views( array $cycles ): array {
+ return array_map(
+ static function ( Cycle $cycle ): CycleView {
+ return CycleView::from_cycle( $cycle );
+ },
+ $cycles
+ );
+ }
+
/**
* Views of row-only contracts (children not loaded).
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Payments.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Payments.php
new file mode 100644
index 00000000000..6f85b0420a1
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Payments.php
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Payments - the engine's broker between extensions and payment gateways.
+ *
+ * Gateways integrate once: they declare the `subscriptions_engine` feature in their
+ * `$supports`, charge on the scheduled-payment action and answer nothing else. What a
+ * gateway can do for a subscription (renewal payments, payment method changes, ...) it
+ * declares with the existing WooCommerce gateway features, which the engine does not
+ * interpret. Extensions ask whether a gateway supports the engine and the features an
+ * operation needs, request a scheduled payment for an order they built, and answer whether an order
+ * needs a reusable payment method. The engine fires and aggregates; it reads no outcome
+ * and writes nothing.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use Throwable;
+use WC_Order;
+use WC_Payment_Gateway;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public payments facade.
+ *
+ * Final and static-only: a stateless entry point, not an extension seam.
+ */
+final class Payments {
+
+ /**
+ * The feature a gateway adds to its `$supports` to integrate with the engine: it charges
+ * an order off-session on `woocommerce_subscriptions_engine_scheduled_payment_{gateway_id}`
+ * and reports the outcome through the order status. It is the only feature the engine
+ * defines; what the gateway can do beyond that it declares with existing gateway features.
+ */
+ public const GATEWAY_FEATURE = 'subscriptions_engine';
+
+ /**
+ * Logger source tag.
+ */
+ private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
+ /**
+ * Whether a registered gateway supports the engine ({@see self::GATEWAY_FEATURE}) and
+ * every feature in `$features`. The features are the caller's to choose (for example
+ * `subscriptions` before charging a renewal); the engine passes them to the gateway's
+ * `supports()` without interpreting them, so core's `woocommerce_payment_gateway_supports`
+ * filter applies to each. False for an empty or unknown id.
+ *
+ * @param string $gateway_id Gateway id.
+ * @param string|string[] $features Gateway feature, or features, the operation needs
+ * besides the engine's. Default none.
+ */
+ public static function gateway_supports( string $gateway_id, $features = array() ): bool {
+ $gateway = self::get_gateway( $gateway_id );
+ if ( null === $gateway ) {
+ return false;
+ }
+
+ foreach ( array_merge( array( self::GATEWAY_FEATURE ), (array) $features ) as $feature ) {
+ if ( ! $gateway->supports( $feature ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Ask the order's gateway to take a scheduled payment for it off-session.
+ *
+ * Fires `woocommerce_subscriptions_engine_scheduled_payment_{gateway_id}` with the order
+ * when the order's gateway supports the engine. Everything else is the caller's: whether
+ * the order should be paid at all (paid, $0 or already in flight) and which further
+ * gateway features the payment needs (such as `subscriptions` for a renewal, checked with
+ * {@see self::gateway_supports()} first). Every false case below is logged.
+ *
+ * The return says whether the request reached the gateway cleanly, never whether the
+ * payment succeeded; in every case the caller reads the outcome from the order status.
+ * - True: the action fired and no callback threw.
+ * - False, in any of three cases the return does not tell apart:
+ * - the order's gateway does not support the engine (nothing was fired; logged as a
+ * warning with the order id),
+ * - the gateway declares support but registered no callback on the action (nothing
+ * was fired; logged as an error with the order id), or
+ * - the gateway threw (logged with the order id): the outcome is unknown, since the
+ * gateway may have charged before throwing.
+ * A caller that sees false and an order still needing payment should not assume the
+ * gateway was never reached.
+ *
+ * @param WC_Order $order Order to pay, carrying the payment method and token.
+ * @return bool True when the action fired and no callback threw; false when the gateway
+ * does not support the engine, has no callback, or threw (outcome unknown).
+ */
+ public static function process_scheduled_payment( WC_Order $order ): bool {
+ $gateway_id = $order->get_payment_method();
+
+ if ( ! self::gateway_supports( $gateway_id ) ) {
+ wc_get_logger()->warning(
+ sprintf( 'Payments::process_scheduled_payment(): order %d was not sent for payment because its gateway "%s" does not support the engine.', $order->get_id(), $gateway_id ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ return false;
+ }
+
+ $hook = 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway_id;
+ if ( ! has_action( $hook ) ) {
+ wc_get_logger()->error(
+ sprintf( 'Payments::process_scheduled_payment(): order %d was not sent for payment because the "%s" gateway declares engine support but has no callback on %s.', $order->get_id(), $gateway_id, $hook ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ return false;
+ }
+
+ try {
+ /**
+ * Fires to take a scheduled payment for an order off-session. The gateway charges the
+ * order total against the payment method and token stored on the order, then reports
+ * the outcome through the order status: paid (`payment_complete()`), `failed` on a
+ * decline, or `on-hold` while an asynchronous charge awaits confirmation.
+ *
+ * @param WC_Order $order The order to pay.
+ */
+ do_action( 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway_id, $order );
+ } catch ( Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'Payments::process_scheduled_payment(): the %s gateway threw while paying order %d: %s', $gateway_id, $order->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Whether an order (or, with null, the current cart) needs a payment method that can be
+ * charged off-session - the question a gateway asks before deciding to save the method.
+ * Extensions answer through the filter below; the engine decides nothing.
+ *
+ * @param WC_Order|null $order The order being paid, or null for the current cart.
+ */
+ public static function requires_reusable_payment_method( ?WC_Order $order = null ): bool {
+ /**
+ * Filters whether an order (or, with a null order, the current cart) needs a payment
+ * method that can be charged off-session. Extensions return true for what they will
+ * charge later; the answer is cast to bool, so any truthy value counts. Several
+ * extensions answer through the same filter, so combine with the incoming value rather
+ * than replace it: `return $requires || $mine;`. A plain `false` would discard another
+ * extension's true.
+ *
+ * @param bool $requires Whether a reusable payment method is required. Default false.
+ * @param WC_Order|null $order The order being paid, or null for the current cart.
+ */
+ $requires = apply_filters( 'woocommerce_subscriptions_engine_requires_reusable_payment_method', false, $order );
+
+ return (bool) $requires;
+ }
+
+ /**
+ * The registered gateway instance for an id, or null.
+ *
+ * @param string $gateway_id Gateway id.
+ */
+ private static function get_gateway( string $gateway_id ): ?WC_Payment_Gateway {
+ $gateway = WC()->payment_gateways()->payment_gateways()[ $gateway_id ] ?? null;
+
+ return $gateway instanceof WC_Payment_Gateway ? $gateway : null;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
index fd91206ecbd..66a6d07de19 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -10,13 +10,8 @@
* reaches a plan of another one (authorization is the caller's concern). Reads return
* read-only {@see PlanView}s. The engine opens no transaction and keeps no cache.
*
- * Billing payload contract: the engine reads one plan payload itself. Until every
- * contract carries a plan snapshot, renewal falls back to the live plan's
- * `billing_policy` and reads it with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}
- * (a string `period` of day, week, month or year, a positive int `interval`, and
- * optional cycle bounds and trial). A payload of another shape is still stored, but
- * renewal parks such a contract. The snapshot's `billing_policy` is read the same way,
- * and one that fails the rule falls back to the live plan.
+ * Plan policies are stored as given; the engine does not read them. An extension may
+ * parse a `billing_policy` with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
deleted file mode 100644
index 8f0455ae92c..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-/**
- * Subscriptions - the engine's interim renewal facade.
- *
- * Renew now and read a contract's related orders. It hides the internal `Core\` /
- * `Integration\` collaborators behind a stable boundary.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Api
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-
-use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\RelatedOrders;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Public subscriptions facade.
- *
- * Interim: removed once renewals move to extensions. Add no new methods here;
- * contract reads and writes live in {@see Contracts}.
- *
- * Final and static-only: a stateless entry point, not an extension seam.
- */
-final class Subscriptions {
-
- /**
- * The orders related to a contract (the origin order, plus renewals / switches /
- * resubscribes), newest first - the portal detail's related-orders read kept
- * facade-only so a consumer never reaches into the order-linkage internals.
- *
- * Returns live `WC_Order` objects; presentation shaping is the caller's job.
- * A long-running contract accumulates one renewal order per period, so paging
- * consumers pass a window; the default stays "all".
- *
- * @param int $contract_id Contract id.
- * @param int $limit Maximum orders to return; any negative (default -1) for all, 0 for none.
- * @param int $offset Orders to skip (for paging). Default 0.
- * @return array<int, WC_Order> Related orders, newest first.
- */
- public static function get_related_orders( int $contract_id, int $limit = -1, int $offset = 0 ): array {
- return ( new RelatedOrders() )->for_contract( $contract_id, $limit, $offset );
- }
-
- /**
- * Renew the contract now on an admin's request, regardless of the schedule. A settled cycle
- * is billed ahead of its due date (the period continues from the previous end, so the schedule
- * is preserved); a failed cycle is retried. Returns null when the contract is not renewable
- * (no chain, awaiting a gateway, or inactive).
- *
- * @param int $contract_id Contract id.
- * @return WC_Order|null The renewal order, or null when the renewal was skipped.
- */
- public static function renew_now( int $contract_id ): ?WC_Order {
- return ( new RenewalEngine() )->renew_now( $contract_id );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
index cdc4b749c56..0d651e317f3 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
@@ -53,7 +53,7 @@ final class ContractView {
/**
* Build a view of a stored contract.
*
- * @internal Built by the engine `Api\` facades only.
+ * @internal Built by the engine only (the `Api\` facades and the due dispatcher).
*
* @param Contract $contract Stored contract.
* @param bool $with_children Whether the read loaded items and addresses.
@@ -270,7 +270,8 @@ final class ContractView {
}
/**
- * Who runs renewals: `primitive` or `gateway`.
+ * Who keeps the payment schedule: `subscriptions_engine` (the engine dispatches the
+ * contract when it is due) or the slug of the gateway or extension that keeps its own.
*/
public function get_schedule_source(): string {
return $this->fields['schedule_source'];
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
index b00aaf7bdc9..eec4e3c508e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
@@ -98,6 +98,13 @@ final class CycleView {
*/
private $order_id;
+ /**
+ * Recorded reason (e.g. why a charge failed), or null.
+ *
+ * @var string|null
+ */
+ private $reason;
+
/**
* Use {@see self::from_cycle()}.
*/
@@ -124,6 +131,7 @@ final class CycleView {
$view->expected_total = $cycle->get_expected_total();
$view->currency = $cycle->get_currency();
$view->order_id = $cycle->get_order_id();
+ $view->reason = $cycle->get_reason();
return $view;
}
@@ -204,4 +212,11 @@ final class CycleView {
public function get_order_id(): ?int {
return $this->order_id;
}
+
+ /**
+ * Recorded reason (e.g. why a charge failed), or null.
+ */
+ public function get_reason(): ?string {
+ return $this->reason;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
index 29a5dec7858..0d8b2fa2b1c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/PlanView.php
@@ -4,8 +4,7 @@
*
* Consumers read plans through this view instead of the Core entity. Getters may be
* added, never removed. The three policies are the owning extension's opaque
- * payloads, returned as stored. The engine itself reads only `billing_policy`, as
- * the renewal fallback for contracts without a plan snapshot (see {@see \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans}).
+ * payloads, returned as stored; the engine does not read them.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
*/
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 50650d299e6..29fbc8508eb 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -41,8 +41,11 @@ defined( 'ABSPATH' ) || exit;
*/
final class Contract {
- public const SCHEDULE_SOURCE_PRIMITIVE = 'primitive';
- public const SCHEDULE_SOURCE_GATEWAY = 'gateway';
+ /**
+ * Schedule source for a contract whose payment schedule the engine keeps
+ * (through its due moment). The only source the engine dispatches.
+ */
+ public const SCHEDULE_SOURCE_ENGINE = 'subscriptions_engine';
public const ADDRESS_BILLING = 'billing';
public const ADDRESS_SHIPPING = 'shipping';
@@ -139,8 +142,8 @@ final class Contract {
private $start_gmt;
/**
- * Live schedule: when the next renewal fires, or null. GMT string. The due scan
- * keys on this; a billing cycle freezes its period from it.
+ * The next due moment, or null. GMT string. The due dispatcher notifies the owner
+ * once it has passed; what happens then is the owner's decision.
*
* @var string|null
*/
@@ -227,7 +230,9 @@ final class Contract {
private $end_gmt;
/**
- * Who runs renewals: 'primitive' (this engine) or 'gateway'.
+ * Who keeps the payment schedule: 'subscriptions_engine' (the engine, through its
+ * due moment) or the slug of a gateway or extension that keeps its own schedule
+ * and triggers its own renewals. The engine dispatches only 'subscriptions_engine'.
*
* @var string
*/
@@ -276,7 +281,7 @@ final class Contract {
$this->last_attempt_gmt = Coercion::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
$this->trial_end_gmt = Coercion::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
$this->end_gmt = Coercion::coerce_nullable_string( $data['end_gmt'] ?? null );
- $this->schedule_source = Coercion::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
+ $this->schedule_source = Coercion::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_ENGINE );
$this->items = Coercion::coerce_list_of_arrays( $data['items'] ?? null );
$this->addresses = Coercion::coerce_map_of_arrays( $data['addresses'] ?? null );
$this->plan_snapshot = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
@@ -485,14 +490,14 @@ final class Contract {
}
/**
- * Next renewal attempt, or null.
+ * The next due moment, or null.
*/
public function get_next_payment_gmt(): ?string {
return $this->next_payment_gmt;
}
/**
- * Set the live schedule (when the next renewal fires).
+ * Set the next due moment.
*
* @param string|null $next_payment_gmt GMT string or null.
*/
@@ -695,17 +700,19 @@ final class Contract {
}
/**
- * Who runs renewals: 'primitive' or 'gateway'.
+ * Who keeps the payment schedule: 'subscriptions_engine' or the slug of the
+ * gateway or extension that keeps its own.
*/
public function get_schedule_source(): string {
return $this->schedule_source;
}
/**
- * Set who runs renewals.
+ * Set who keeps the payment schedule.
*
- * @param string $schedule_source 'primitive' or 'gateway'.
- * @throws DomainException If `$schedule_source` is neither.
+ * @param string $schedule_source 'subscriptions_engine' or the slug of the gateway or
+ * extension that keeps its own schedule.
+ * @throws DomainException If `$schedule_source` is not a valid slug.
*/
public function set_schedule_source( string $schedule_source ): void {
self::assert_schedule_source( $schedule_source );
@@ -752,7 +759,7 @@ final class Contract {
/**
* Serialize the contract row (excluding generated id/timestamps).
*
- * @return array<string, mixed>
+ * @return array<string, int|string|null>
*/
public function to_storage(): array {
return array(
@@ -806,13 +813,15 @@ final class Contract {
}
/**
- * Refuse a schedule source other than 'primitive' or 'gateway'.
+ * Refuse a schedule source that is not a slug of 1-64 lowercase letters, digits,
+ * underscores or hyphens (the column is VARCHAR(64), wide enough for extension and
+ * gateway slugs).
*
* @param string $schedule_source Schedule source to check.
- * @throws DomainException If `$schedule_source` is neither.
+ * @throws DomainException If `$schedule_source` is not a valid slug.
*/
private static function assert_schedule_source( string $schedule_source ): void {
- if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
+ if ( 1 !== preg_match( '/^[a-z0-9_-]{1,64}$/D', $schedule_source ) ) {
throw new DomainException( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ) );
}
}
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 9f1f3417e2b..95db5d2b9a4 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -4,8 +4,8 @@
* `(contract_id, kind)` with counters derived from its rows (not a stored entity).
*
* An immutable billing record frozen at billing (snapshot references, period
- * boundaries, `expected_total`); only `status`, `order_id`, and `reason` may change
- * afterwards. The `count` is the chargeable number and idempotency anchor, nullable
+ * boundaries, `expected_total`). Only the repository's compare-and-set tools change a
+ * stored cycle's `status`, `order_id`, `reason` and claim afterwards. The `count` is the chargeable number and idempotency anchor, nullable
* for non-counting cycles (e.g. a future trial). Money is a decimal-safe string;
* timestamps are GMT strings (`Y-m-d H:i:s`).
*
@@ -143,9 +143,9 @@ final class Cycle {
private $extension_slug;
/**
- * Crash-recovery lease expiry (GMT string), or null. Set when a `pending` cycle is
- * claimed; a stuck pending cycle past this moment is reclaimable. The create-as-claim
- * UNIQUE index is the primary concurrency guard - this only recovers a crashed charge.
+ * Claim expiry (GMT string), or null. Set when a caller appends a cycle claimed, claims
+ * it, or transitions it with a claim; once it passes, another caller may claim the
+ * cycle. The chain's UNIQUE positions remain the primary guard against a duplicate cycle.
*
* @var string|null
*/
@@ -160,14 +160,14 @@ final class Cycle {
private $retry_at_gmt;
/**
- * Typed plan snapshot held in memory, or null.
+ * Typed plan snapshot attached on read from `plan_snapshot_id`, or null.
*
* @var PlanSnapshot|null
*/
private $plan_snapshot;
/**
- * Typed items snapshot held in memory, or null.
+ * Typed items snapshot attached on read from `items_snapshot_id`, or null.
*
* @var ItemsSnapshot|null
*/
@@ -197,8 +197,6 @@ final class Cycle {
$this->extension_slug = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
$this->claimed_until_gmt = Coercion::coerce_nullable_string( $data['claimed_until'] ?? null );
$this->retry_at_gmt = Coercion::coerce_nullable_string( $data['retry_at'] ?? null );
- $this->plan_snapshot = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
- $this->items_snapshot = ( $data['items_snapshot'] ?? null ) instanceof ItemsSnapshot ? $data['items_snapshot'] : null;
}
/**
@@ -208,15 +206,16 @@ final class Cycle {
* `currency`. Optional: `sequence_no` (absent or null: the append assigns the chain's
* next position), `count` (absent or null for a non-counting cycle; the caller owns
* the numbering), `status` (defaults to `pending`), `kind` (defaults to billing), `reason`,
- * `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`,
- * `plan_snapshot`, `items_snapshot`.
+ * `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`. Snapshot value
+ * objects are attached only when a stored cycle is read.
*
* @param array<string, mixed> $args Cycle attributes.
* @throws DomainException If the attributes are not valid.
*/
public static function create( array $args ): self {
- // A new cycle is always unsaved; never adopt a caller-supplied id.
- unset( $args['id'] );
+ // A new cycle is always unsaved; never adopt a caller-supplied id. A claim is
+ // stamped by the database when the cycle is appended, never carried in.
+ unset( $args['id'], $args['claimed_until'] );
$cycle = new self( $args );
@@ -244,9 +243,6 @@ final class Cycle {
* @throws DomainException If the stored status is not a well-formed status slug.
*/
public static function from_storage( array $row ): self {
- // The typed snapshot value objects are attached on load, never hydrated here.
- unset( $row['plan_snapshot'], $row['items_snapshot'] );
-
return new self( $row );
}
@@ -333,27 +329,6 @@ final class Cycle {
return $this->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 status changes to an unregistered value.
- */
- public function set_status( CycleStatus $status ): void {
- if ( $this->status->equals( $status ) ) {
- return;
- }
-
- self::assert_status( $status );
-
- $this->status = $status;
- }
-
/**
* Open-ended annotation, or null.
*/
@@ -361,15 +336,6 @@ final class Cycle {
return $this->reason;
}
- /**
- * Annotate the cycle with an open-ended reason. Always writable.
- *
- * @param string|null $reason Reason text, or null to clear.
- */
- public function set_reason( ?string $reason ): void {
- $this->reason = $reason;
- }
-
/**
* Period start (GMT string). Frozen at construction.
*/
@@ -405,18 +371,6 @@ final class Cycle {
return $this->plan_snapshot_id;
}
- /**
- * Record the plan snapshot row id once it is stored. Write-once: only fills an
- * unset id, never re-points the frozen reference.
- *
- * @param int $plan_snapshot_id Snapshot row id.
- * @throws DomainException If a plan snapshot id is already recorded.
- */
- public function set_plan_snapshot_id( int $plan_snapshot_id ): void {
- $this->assert_snapshot_ref_unset( 'plan_snapshot_id', $this->plan_snapshot_id );
- $this->plan_snapshot_id = $plan_snapshot_id;
- }
-
/**
* Items snapshot row id, or null.
*/
@@ -424,18 +378,6 @@ final class Cycle {
return $this->items_snapshot_id;
}
- /**
- * Record the items snapshot row id once it is stored. Write-once companion to
- * {@see self::set_plan_snapshot_id()}.
- *
- * @param int $items_snapshot_id Snapshot row id.
- * @throws DomainException If an items snapshot id is already recorded.
- */
- public function set_items_snapshot_id( int $items_snapshot_id ): void {
- $this->assert_snapshot_ref_unset( 'items_snapshot_id', $this->items_snapshot_id );
- $this->items_snapshot_id = $items_snapshot_id;
- }
-
/**
* Order id linked to this cycle, or null.
*/
@@ -443,16 +385,6 @@ final class Cycle {
return $this->order_id;
}
- /**
- * Link the cycle to its order. One of the few post-freeze-mutable fields (with
- * `status` and `reason`): the order id is stamped once the charge order exists.
- *
- * @param int $order_id Linked order id.
- */
- public function set_order_id( int $order_id ): void {
- $this->order_id = $order_id;
- }
-
/**
* Owning extension slug, or null.
*/
@@ -461,22 +393,12 @@ final class Cycle {
}
/**
- * Crash-recovery lease expiry (GMT string), or null.
+ * Crash-recovery claim expiry (GMT string), or null.
*/
public function get_claimed_until_gmt(): ?string {
return $this->claimed_until_gmt;
}
- /**
- * Set (or clear) the crash-recovery lease expiry. Post-freeze-mutable (with `status`,
- * `order_id`, `reason`): a claim stamps it, and a reclaim/resolve may re-stamp or clear it.
- *
- * @param string|null $claimed_until_gmt Lease expiry GMT string, or null to clear.
- */
- public function set_claimed_until_gmt( ?string $claimed_until_gmt ): void {
- $this->claimed_until_gmt = $claimed_until_gmt;
- }
-
/**
* Reserved retry moment (GMT string), or null.
*/
@@ -484,15 +406,6 @@ final class Cycle {
return $this->retry_at_gmt;
}
- /**
- * Set (or clear) the reserved retry moment. Reserved for a later retry/dunning pass.
- *
- * @param string|null $retry_at_gmt Retry moment GMT string, or null to clear.
- */
- public function set_retry_at_gmt( ?string $retry_at_gmt ): void {
- $this->retry_at_gmt = $retry_at_gmt;
- }
-
/**
* Typed plan snapshot held in memory, or null.
*/
@@ -539,7 +452,7 @@ final class Cycle {
* Serialize the cycle row (excluding the generated id/timestamps). Status is
* stored as its plain string value.
*
- * @return array<string, mixed>
+ * @return array<string, int|string|null>
*/
public function to_storage(): array {
return array(
@@ -562,22 +475,6 @@ final class Cycle {
);
}
- /**
- * Guard a snapshot row-id stamp so it is write-once; a second stamp would
- * re-point a frozen reference.
- *
- * @param string $field Field being stamped, for the error message.
- * @param int|null $current The currently recorded id (must be null to stamp).
- * @throws DomainException If the id is already recorded.
- */
- private function assert_snapshot_ref_unset( string $field, ?int $current ): void {
- if ( null !== $current ) {
- throw new DomainException(
- sprintf( 'Cycle: "%s" is frozen and cannot be re-pointed.', $field )
- );
- }
- }
-
/**
* Refuse a missing (non-positive) owning contract id.
*
@@ -643,8 +540,8 @@ 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
+ * Reject a status value that is not registered. Write path only
+ * ({@see self::create()}); storage hydration keeps an
* unregistered stored value verbatim.
*
* @param CycleStatus $status Status to check.
@@ -661,8 +558,7 @@ final class Cycle {
/**
* Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
* 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.
+ * Registration is checked by {@see self::create()}, 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
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 bb0852845e3..75f2ec2fcf1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -26,8 +26,7 @@ defined( 'ABSPATH' ) || exit;
* 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).
+ * ({@see Cycle::create()} and the repository's status compare-and-set).
*/
final class CycleStatus {
@@ -67,15 +66,6 @@ final class CycleStatus {
return $this->value;
}
- /**
- * Whether this status is the same as `$other`.
- *
- * @param CycleStatus $other Status to compare against.
- */
- public function equals( CycleStatus $other ): bool {
- return $this->value === $other->value;
- }
-
/**
* The engine's default cycle statuses (the registry seed).
*
@@ -120,17 +110,4 @@ final class CycleStatus {
public static function is_valid( string $status ): bool {
return StatusRegistry::is_valid_slug( $status );
}
-
- /**
- * Whether `$status` is `billed` or `cancelled`.
- *
- * 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.
- *
- * @param string $status Status to check.
- */
- 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/Gateway/GatewayCapabilities.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Gateway/GatewayCapabilities.php
deleted file mode 100644
index 02cf899ee70..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Gateway/GatewayCapabilities.php
+++ /dev/null
@@ -1,159 +0,0 @@
-<?php
-/**
- * GatewayCapabilities - the pure store of declared gateway capabilities.
- *
- * Payment gateways tell the engine which subscription features they can handle:
- * "I can process recurring charges," "I tolerate variable amounts," "I run my
- * own renewal schedule." This Core class owns the in-memory declarations and
- * the predicate over them; the WordPress-facing entry point lives in
- * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry},
- * which delegates registration here and layers the live-gateway and filter
- * resolution steps on top.
- *
- * Core zone: WordPress-free by design. No WP/Woo symbols, no time functions.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway;
-
-use InvalidArgumentException;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Pure storage and predicate for gateway capability declarations.
- */
-final class GatewayCapabilities {
-
- /**
- * Gateway can process engine-scheduled recurring charges.
- *
- * The most fundamental flag: an engine-scheduled contract on a gateway
- * lacking this capability would create renewals nobody runs.
- */
- public const RECURRING = 'recurring';
-
- /**
- * Customer can change the payment method on an active contract. All modern
- * gateways support this; the flag exists for the rare manual-only case.
- */
- public const PAYMENT_METHOD_CHANGE = 'payment_method_change';
-
- /**
- * Gateway tolerates variable charge amounts (volume-tier upgrades, tax-rate
- * changes, prorated mid-cycle adjustments). Flag the difference so
- * amount-changing flows can refuse on an incapable gateway rather than
- * silently mis-charging.
- */
- public const AMOUNT_CHANGES = 'amount_changes';
-
- /**
- * One customer can hold N active contracts. Default for modern gateways;
- * the flag exists for single-mandate gateways.
- */
- public const MULTIPLE_PER_CUSTOMER = 'multiple_per_customer';
-
- /**
- * Gateway schedules and fires renewals itself; the engine only tracks
- * metadata. Pairs with the gateway `schedule_source` at the contract level -
- * a gateway can declare this capability without every contract on it being
- * gateway-scheduled.
- */
- public const GATEWAY_SCHEDULED_RENEWALS = 'gateway_scheduled_renewals';
-
- /**
- * In-memory declarations, keyed by gateway id => list of declared capability
- * flag strings (de-duplicated and reindexed).
- *
- * Static (not instance state) because the public registration API is itself
- * static - every consumer reaches the registry by class name, not via an
- * injected instance.
- *
- * @var array<string, array<int, string>>
- */
- private static $declarations = array();
-
- /**
- * Every recognised capability flag.
- *
- * Public so test code, admin tooling, and future capability-matrix UIs can
- * iterate the canonical list without hard-coding the constants.
- *
- * @return array<int, string>
- */
- public static function known_capabilities(): array {
- return array(
- self::RECURRING,
- self::PAYMENT_METHOD_CHANGE,
- self::AMOUNT_CHANGES,
- self::MULTIPLE_PER_CUSTOMER,
- self::GATEWAY_SCHEDULED_RENEWALS,
- );
- }
-
- /**
- * Register a gateway's declared capabilities.
- *
- * Each entry in `$capabilities` must be one of the flag constants on this
- * class; an unknown flag throws so a typo surfaces at registration rather
- * than silently defaulting to "unsupported" later.
- *
- * Re-declaration replaces: if a gateway declares twice, the second call wins
- * outright - we do not merge. Merging would make "I removed this capability"
- * impossible to express, and it mirrors the replace-not-merge semantics of
- * WooCommerce's own feature-compatibility declarations.
- *
- * @param string $gateway_id Gateway identifier.
- * @param array<int, string> $capabilities Capability flag strings; each must be one of {@see self::known_capabilities()}.
- * @throws InvalidArgumentException When `$capabilities` contains a flag not in {@see self::known_capabilities()}.
- */
- public static function declare( string $gateway_id, array $capabilities ): void {
- $known = self::known_capabilities();
- foreach ( $capabilities as $capability ) {
- if ( ! in_array( $capability, $known, true ) ) {
- throw new InvalidArgumentException(
- sprintf(
- 'GatewayCapabilities: unknown capability flag "%s". Expected one of: %s.',
- is_string( $capability ) ? $capability : gettype( $capability ),
- implode( ', ', $known )
- )
- );
- }
- }
-
- // De-dupe and reindex before storing: the public API documents a "list
- // of strings" shape, so double-declarations should not leak through
- // lookups indistinguishably.
- self::$declarations[ $gateway_id ] = array_values( array_unique( $capabilities ) );
- }
-
- /**
- * Whether `$gateway_id` has declared `$capability`.
- *
- * A pure predicate over the static declarations only - it does not consult
- * any live gateway or filter. Returns false for unknown gateway ids and
- * unknown capability strings; the strict path is {@see self::declare()},
- * which throws on typos at registration.
- *
- * @param string $gateway_id Gateway identifier.
- * @param string $capability Capability flag.
- */
- public static function is_declared( string $gateway_id, string $capability ): bool {
- $declared = self::$declarations[ $gateway_id ] ?? array();
-
- return in_array( $capability, $declared, true );
- }
-
- /**
- * Clear every declaration.
- *
- * @internal Public only so test setUp can isolate per-test state. Not part
- * of the consumer API.
- */
- public static function reset(): void {
- self::$declarations = array();
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
deleted file mode 100644
index d45da773209..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
+++ /dev/null
@@ -1,125 +0,0 @@
-<?php
-/**
- * RenewalCalculator - pure renewal date math and the next-cycle builder.
- *
- * Answers the questions the renewal money-path needs with no knowledge of
- * WordPress, Action Scheduler, orders, or the wall clock: given a plan's
- * {@see BillingPolicy} and where a contract is in its chain,
- *
- * 1. has the contract reached its hard end (max_cycles)?
- * 2. when is the next bill date, computed from an explicit anchor?
- * 3. what is the next {@see Cycle} the contract advances into?
- *
- * `$now`/anchors are always passed in by the integration layer so the math is
- * deterministic and unit-testable. All cadence math delegates to
- * {@see BillingPolicy} so there is a single cadence path in the package.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Pure renewal calculator.
- *
- * Stateless: every method is static and derives its answer solely from its
- * arguments. The integration layer (`Integration\Renewal\RenewalEngine`) reads
- * the contract row, resolves its billing policy, calls in here for the next
- * cycle, then persists it and talks to Action Scheduler / the gateway.
- */
-final class RenewalCalculator {
-
- /**
- * Whether a contract that has paid `$cycle_count` cycles has reached the
- * policy's hard end.
- *
- * `max_cycles` counts total billed cycles. A contract is terminal once its
- * paid-cycle count is at or past `max_cycles`. Open-ended policies
- * (`max_cycles === null`) never reach a hard end this way - they run until
- * cancelled.
- *
- * The comparison is `>=`, not `===`: a contract that somehow over-counted
- * (a replayed webhook that slipped the idempotency gate, a migrated row)
- * should still be treated as terminal rather than billing forever past its
- * cap.
- *
- * @param BillingPolicy $policy The plan's billing policy.
- * @param int $cycle_count Count of successfully-billed cycles so far.
- */
- public static function has_reached_max_cycles( BillingPolicy $policy, int $cycle_count ): bool {
- $max_cycles = $policy->get_max_cycles();
-
- if ( null === $max_cycles ) {
- return false;
- }
-
- return $cycle_count >= $max_cycles;
- }
-
- /**
- * Compute the next bill date one cadence past `$current_period_start`.
- *
- * Delegates to {@see BillingPolicy::compute_next_renewal_from()} so calendar
- * semantics (month-end roll-over, DST) stay in one place. The result is in
- * UTC; the caller formats it to the `Y-m-d H:i:s` GMT string the rows store.
- *
- * @param BillingPolicy $policy The plan's billing policy.
- * @param DateTimeImmutable $current_period_start The anchor the next cycle is measured from.
- * @return DateTimeImmutable The next bill date, in UTC.
- */
- public static function next_bill_date( BillingPolicy $policy, DateTimeImmutable $current_period_start ): DateTimeImmutable {
- return $policy->compute_next_renewal_from( $current_period_start );
- }
-
- /**
- * Build the next cycle a contract advances into, one cadence forward from the
- * anchor the caller supplies.
- *
- * The period runs from `period_start` one cadence forward (calendar-aware, via
- * the billing policy) - a real one-cadence period, never zero-duration. The
- * amount, currency, count, and snapshot references are the contract's live
- * values, passed in by the caller, so the cycle reflects the contract rather
- * than a possibly-stale earlier cycle. The returned cycle is `pending`, ready
- * for the integration layer to claim (insert) and then charge.
- *
- * @param BillingPolicy $policy The billing policy whose cadence the period runs on.
- * @param array<string, mixed> $values Cycle inputs: `contract_id`, `sequence_no`, `count`,
- * `period_start` (GMT `Y-m-d H:i:s` anchor), `expected_total`,
- * `currency`, `extension_slug`, `plan_snapshot_id`,
- * `items_snapshot_id`.
- * @return Cycle The pending next cycle.
- */
- public static function compute_next_cycle( BillingPolicy $policy, array $values ): Cycle {
- $anchor = $values['period_start'] ?? null;
- $start = new DateTimeImmutable( is_string( $anchor ) ? $anchor : '', new DateTimeZone( 'UTC' ) );
- $end = $policy->compute_next_renewal_from( $start );
-
- // Cycle::create() coerces and validates each attribute, so the contract's values pass
- // through as given; only the period boundaries are computed here.
- return Cycle::create(
- array(
- 'contract_id' => $values['contract_id'] ?? null,
- 'sequence_no' => $values['sequence_no'] ?? null,
- 'count' => $values['count'] ?? null,
- '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,
- 'currency' => $values['currency'] ?? null,
- 'extension_slug' => $values['extension_slug'] ?? null,
- 'plan_snapshot_id' => $values['plan_snapshot_id'] ?? null,
- 'items_snapshot_id' => $values['items_snapshot_id'] ?? null,
- )
- );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index d9a4eedbb92..41ee494bdd1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -2,15 +2,10 @@
/**
* BillingPolicy - an optional parser for a billing cadence and trial payload.
*
- * An extension may use {@see self::from_array()} to parse its plan billing arrays. The
- * engine stores plan policies opaquely and does not construct this on plan writes. It
- * does read one payload with it: renewal parses a contract's plan snapshot
- * `billing_policy`, and the live plan's `billing_policy` when the contract has no usable
- * snapshot policy, both through {@see self::from_array()}, so a plan whose
- * contracts the engine renews must store this shape there. A policy always has a
- * usable cadence: construction refuses an unknown period or a non-positive interval.
- * The array
- * shape it parses:
+ * An extension may use {@see self::from_array()} to parse its plan billing arrays and
+ * compute due moments from them. The engine stores plan policies opaquely and does not
+ * read them itself. A policy always has a usable cadence: construction refuses an unknown
+ * period or a non-positive interval. The array shape it parses:
* {
* period: 'day' | 'week' | 'month' | 'year',
* interval: int,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PaymentInstrumentRef.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PaymentInstrumentRef.php
index e39536c281a..628fddc97e5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PaymentInstrumentRef.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PaymentInstrumentRef.php
@@ -4,8 +4,8 @@
*
* Carries the payment token id plus the gateway code and human-readable title
* frozen at the time the instrument was attached. The Core zone never loads a
- * live payment token; the Payments host binding resolves the reference when a
- * charge is attempted.
+ * live payment token; the extension that builds an order to charge carries the
+ * token onto it, and the gateway charges against the order.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index f02b1e23ac2..c157de3a275 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -101,9 +101,8 @@ final class PlanSnapshot {
}
/**
- * The frozen billing cadence captured at signup, parsed with the engine's renewal
- * rule ({@see BillingPolicy::from_array()}), so it holds after
- * the source plan is edited or deleted. Null when the payload carries no billing
+ * The frozen billing cadence captured at signup, parsed with
+ * {@see BillingPolicy::from_array()}, so it holds after the source plan is edited or deleted. Null when the payload carries no billing
* policy array; throws when one is present but unusable, so a caller can log why
* before falling back.
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 6db119659ce..bcbdc672e08 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -15,10 +15,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\ContractsController;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ContractCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
@@ -47,32 +45,26 @@ final class Bootstrap {
self::$initialized = true;
- CapabilityRegistry::init();
ContractCapabilities::register_hooks();
-
- // Register the callbacks that dispatch renewals back into the engine. Plain
- // add_action calls, safe before Action Scheduler has loaded; must run on every
- // boot (not just activation) so the hooks can fire.
- ( new RenewalEngine() )->register_hooks();
PlansController::register_hooks();
ContractsController::register_hooks();
- ( new RenewalDispatcher() )->register_hooks();
+ ( new DueDispatcher() )->register_hooks();
- // Deferred boot work, each on the most specific moment it needs: the schema install
- // reads options and runs dbDelta, so it waits for `init`; the recurring-action
- // enqueue needs the `as_*` functions, so it waits for `action_scheduler_init` - the
- // hook Action Scheduler fires once it is ready. Run immediately when a consumer
- // boots the engine after a hook already fired.
+ // Deferred boot work, each on the moment it needs: the schema install reads options
+ // and runs dbDelta, so it waits for `init`; the recurring-scan check waits for
+ // `wp_loaded`, when Action Scheduler is up and consumers registering on `init` have
+ // registered their due handlers (one registered later schedules the scan itself).
+ // Run immediately when a consumer boots the engine after a hook already fired.
if ( did_action( 'init' ) ) {
SchemaInstaller::maybe_install();
} else {
add_action( 'init', array( SchemaInstaller::class, 'maybe_install' ) );
}
- if ( did_action( 'action_scheduler_init' ) ) {
- RenewalDispatcher::ensure_scheduled();
+ if ( did_action( 'wp_loaded' ) ) {
+ DueDispatcher::ensure_scheduled();
} else {
- add_action( 'action_scheduler_init', array( RenewalDispatcher::class, 'ensure_scheduled' ) );
+ add_action( 'wp_loaded', array( DueDispatcher::class, 'ensure_scheduled' ) );
}
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
deleted file mode 100644
index 94c975e157d..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php
-/**
- * Order-side meta keys linking renewal-side orders (renewals, switches, resubscribes)
- * to contracts, making the relationship queryable from the order side. The origin
- * order is linked by the contract's `origin_order_id` instead. The engine owns these
- * keys; consumers read them through this class rather than hard-coding the strings.
- *
- * Written to WooCommerce order meta, which works under both HPOS and the legacy
- * CPT order store.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Contract <-> order meta-key catalogue.
- */
-final class OrderLinkage {
-
- /**
- * Order meta key holding the contract id this order belongs to.
- *
- * Stored as a stringified integer (order meta is a flat string table).
- */
- public const META_CONTRACT_ID = '_subscription_contract_id';
-
- /**
- * Order meta key holding the relation type - see the `RELATION_*` constants.
- */
- public const META_RELATION_TYPE = '_subscription_relation_type';
-
- /**
- * A renewal order - created by the renewal engine when a cycle bills.
- */
- public const RELATION_RENEWAL = 'renewal';
-
- /**
- * A switch order - customer moved between plans.
- */
- public const RELATION_SWITCH = 'switch';
-
- /**
- * A resubscribe order - customer restarted a previously-cancelled contract.
- */
- public const RELATION_RESUBSCRIBE = 'resubscribe';
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
deleted file mode 100644
index 21f8daaa374..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
+++ /dev/null
@@ -1,96 +0,0 @@
-<?php
-/**
- * RelatedOrders - reads the orders related to a contract: the origin order by the
- * contract's `origin_order_id`, plus the orders tagged with the {@see OrderLinkage} meta
- * (renewals / switches / resubscribes). Returns live WC_Order objects newest first;
- * shaping them for presentation is the caller's job.
- *
- * Integration zone: WordPress-native. The flat `meta_key`/`meta_value` lookup round-trips
- * through both the HPOS and legacy order stores.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
-
-use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Read of a contract's related orders.
- */
-final class RelatedOrders {
-
- /**
- * The orders related to `$contract_id`, newest first.
- *
- * The origin order is read by the contract's `origin_order_id`; the others through the
- * order-side {@see OrderLinkage::META_CONTRACT_ID} meta. An order found both ways appears
- * once. Returns an empty array for an unknown contract or when none are related.
- *
- * The window args exist because a long-running contract accumulates one renewal
- * order per period, so paging consumers pass a window. The default stays "all".
- *
- * @param int $contract_id Contract id.
- * @param int $limit Maximum orders to return; any negative (default -1) for all, 0 for none.
- * @param int $offset Orders to skip (for paging). Default 0.
- * @return array<int, WC_Order> Related orders, newest first.
- */
- public function for_contract( int $contract_id, int $limit = -1, int $offset = 0 ): array {
- if ( 0 === $limit ) {
- return array();
- }
-
- $contract = ( new ContractRepository() )->find_summary( $contract_id );
- if ( null === $contract ) {
- return array();
- }
-
- $by_id = array();
-
- $origin_id = $contract->get_origin_order_id();
- $origin = null === $origin_id ? false : wc_get_order( $origin_id );
- if ( $origin instanceof WC_Order ) {
- $by_id[ $origin->get_id() ] = $origin;
- }
-
- // The origin adds at most one entry, so the newest `$offset + $limit` linked orders
- // always cover the requested page. Order like the merge below: newest first, then id.
- $linked = wc_get_orders(
- array(
- 'limit' => $limit < 0 ? -1 : max( 0, $offset ) + $limit,
- 'orderby' => array(
- 'date' => 'DESC',
- 'ID' => 'DESC',
- ),
- '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
- )
- );
- foreach ( is_array( $linked ) ? $linked : array() as $order ) {
- if ( $order instanceof WC_Order ) {
- $by_id[ $order->get_id() ] = $order;
- }
- }
-
- $orders = array_values( $by_id );
- usort(
- $orders,
- static function ( WC_Order $a, WC_Order $b ): int {
- $a_created = $a->get_date_created();
- $b_created = $b->get_date_created();
- $by_date = ( null === $b_created ? 0 : $b_created->getTimestamp() ) <=> ( null === $a_created ? 0 : $a_created->getTimestamp() );
-
- return 0 !== $by_date ? $by_date : $b->get_id() <=> $a->get_id();
- }
- );
-
- return array_slice( $orders, max( 0, $offset ), $limit < 0 ? null : $limit );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php
new file mode 100644
index 00000000000..aa113aeec00
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Dispatch/DueDispatcher.php
@@ -0,0 +1,239 @@
+<?php
+/**
+ * DueDispatcher - notifies owners when their contracts are due.
+ *
+ * One recurring Action Scheduler job scans for contracts whose `next_payment_gmt` has
+ * passed and whose owner registered a `contract_due_handler` ({@see ConsumerRegistry}),
+ * claims each one, and calls the owner's handler. Only contracts whose schedule the engine
+ * keeps (`schedule_source` `subscriptions_engine`) are dispatched; a gateway or extension
+ * that keeps its own schedule triggers its own renewals. The engine decides nothing about
+ * what a due contract means: renewing, skipping or clearing the due moment is the owner's call.
+ * A handler is expected to move or clear the due moment; one that does not is notified
+ * again after the claim expires, behind contracts not yet notified.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Throwable;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Batch due dispatcher.
+ */
+final class DueDispatcher {
+
+ /**
+ * Action Scheduler hook fired on each recurring scan tick.
+ */
+ public const HOOK = 'woocommerce_subscriptions_engine_dispatch_due_contracts';
+
+ /**
+ * Action Scheduler group shared with the rest of the engine's actions.
+ */
+ public const GROUP = 'woocommerce_subscriptions_engine';
+
+ /**
+ * How long a dispatch claim hides a notified contract from later scans, in seconds,
+ * measured on the database clock. Moving or clearing `next_payment_gmt` releases the
+ * claim early; re-saving the same moment does not. A handler that leaves the due moment
+ * in place is notified again once the claim expires, behind contracts not yet notified
+ * for their current due moment.
+ */
+ public const CLAIM_TTL_SECONDS = 900;
+
+ /**
+ * Scan cadence, in seconds (every 10 minutes).
+ */
+ private const INTERVAL_SECONDS = 600;
+
+ /**
+ * Contracts notified per tick; a backlog drains over successive ticks.
+ */
+ private const BATCH_SIZE = 50;
+
+ /**
+ * Logger source tag.
+ */
+ private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
+ /**
+ * Option holding the next moment the recurring action is re-verified against the Action
+ * Scheduler store, so the common request path skips the store query.
+ */
+ private const SCHEDULE_CHECK_OPTION = 'woocommerce_subscriptions_engine_due_dispatch_scheduled_check';
+
+ /**
+ * How long a positive schedule check is trusted before re-verifying, in seconds.
+ */
+ private const SCHEDULE_RECHECK_SECONDS = 3600;
+
+ /**
+ * Repository used to scan, claim and read due contracts.
+ *
+ * @var ContractRepository
+ */
+ private $contracts;
+
+ /**
+ * Build a dispatcher.
+ */
+ public function __construct() {
+ $this->contracts = new ContractRepository();
+ }
+
+ /**
+ * Register the scan-tick handler. Runs on every boot so Action Scheduler can dispatch a
+ * tick into {@see self::handle_tick()}; the recurring action itself is enqueued by
+ * {@see self::ensure_scheduled()}.
+ */
+ public function register_hooks(): void {
+ add_action( self::HOOK, array( $this, 'handle_tick' ) );
+ }
+
+ /**
+ * Enqueue the recurring scan action when an owner has a due handler, or remove it when none does.
+ *
+ * A positive check is cached in an autoloaded option and re-verified once per re-check
+ * window, so most requests skip the Action Scheduler store query. Runs on `wp_loaded`,
+ * once consumers have registered, and again when a due handler registers after that
+ * ({@see ConsumerRegistry::register()}). Without Action Scheduler (for
+ * example from a deactivation hook while WooCommerce is inactive) it does nothing.
+ */
+ public static function ensure_scheduled(): void {
+ if ( ! self::action_scheduler_available() ) {
+ return;
+ }
+
+ if ( array() === ConsumerRegistry::get_slugs_with_contract_due_handler() ) {
+ if ( false !== get_option( self::SCHEDULE_CHECK_OPTION, false ) ) {
+ as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
+ delete_option( self::SCHEDULE_CHECK_OPTION );
+ }
+ return;
+ }
+
+ $next_check = get_option( self::SCHEDULE_CHECK_OPTION, 0 );
+ if ( is_numeric( $next_check ) && time() < (int) $next_check ) {
+ return;
+ }
+
+ if ( ! self::is_scheduled() ) {
+ // `$unique = true` is a best-effort dedup; the per-contract claim keeps a
+ // duplicate recurring action from notifying a contract twice.
+ as_schedule_recurring_action( time() + self::INTERVAL_SECONDS, self::INTERVAL_SECONDS, self::HOOK, array(), self::GROUP, true );
+ }
+
+ update_option( self::SCHEDULE_CHECK_OPTION, time() + self::SCHEDULE_RECHECK_SECONDS, true );
+ }
+
+ /**
+ * Whether the Action Scheduler functions the schedule check calls are loaded.
+ */
+ private static function action_scheduler_available(): bool {
+ return function_exists( 'as_next_scheduled_action' )
+ && function_exists( 'as_schedule_recurring_action' )
+ && function_exists( 'as_unschedule_all_actions' );
+ }
+
+ /**
+ * Whether the recurring scan action is currently scheduled (queries the Action Scheduler store).
+ */
+ private static function is_scheduled(): bool {
+ return false !== as_next_scheduled_action( self::HOOK, array(), self::GROUP );
+ }
+
+ /**
+ * The Action Scheduler entry point, fired once per tick. Argument-less so a stray
+ * `do_action( self::HOOK, ... )` cannot reach the typed parameters of {@see self::run_batch()}.
+ */
+ public function handle_tick(): void {
+ $this->run_batch();
+ }
+
+ /**
+ * Notify the owners of up to `$limit` due contracts.
+ *
+ * Each due contract is claimed first (a lost claim is skipped), then its owner's handler is
+ * called with the contract view and `array( 'due_at' => DateTimeImmutable )`. A throwing
+ * handler is logged and keeps its claim, so one contract never stalls the batch.
+ *
+ * @param DateTimeImmutable|null $now The scan moment; defaults to now (UTC).
+ * @param int $limit Maximum due contracts this tick; non-positive is a no-op.
+ * @return int The number of contracts whose handler returned without throwing; lost
+ * claims, missing contracts or handlers, and throwing handlers are not counted.
+ */
+ public function run_batch( ?DateTimeImmutable $now = null, int $limit = self::BATCH_SIZE ): int {
+ if ( $limit < 1 ) {
+ return 0;
+ }
+
+ $owners = ConsumerRegistry::get_slugs_with_contract_due_handler();
+ if ( array() === $owners ) {
+ wc_get_logger()->info(
+ 'DueDispatcher::run_batch(): no consumer registered a contract due handler - nothing to dispatch.',
+ array( 'source' => self::LOG_SOURCE )
+ );
+ return 0;
+ }
+
+ $utc = new DateTimeZone( 'UTC' );
+ $scan_now = $now ?? new DateTimeImmutable( 'now', $utc );
+ $due_rows = $this->contracts->find_due( $scan_now, $limit, $owners );
+
+ $notified = 0;
+ $skipped = 0;
+ $failed = 0;
+
+ foreach ( $due_rows as $row ) {
+ $contract_id = $row['id'];
+
+ if ( ! $this->contracts->claim_contract( $contract_id, $row['next_payment_gmt'], self::CLAIM_TTL_SECONDS ) ) {
+ ++$skipped;
+ continue;
+ }
+
+ $contract = $this->contracts->find( $contract_id );
+ $handler = ConsumerRegistry::get_contract_due_handler( $row['extension_slug'] );
+ if ( null === $contract || null === $handler ) {
+ ++$skipped;
+ continue;
+ }
+
+ try {
+ $handler(
+ ContractView::from_contract( $contract, true ),
+ array( 'due_at' => new DateTimeImmutable( $row['next_payment_gmt'], $utc ) )
+ );
+ ++$notified;
+ } catch ( Throwable $e ) {
+ ++$failed;
+ wc_get_logger()->error(
+ sprintf( 'DueDispatcher::run_batch(): the due handler for contract %d threw: %s', $contract_id, $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => $contract_id,
+ )
+ );
+ }
+ }
+
+ if ( array() !== $due_rows ) {
+ wc_get_logger()->debug(
+ sprintf( 'DueDispatcher::run_batch(): %d due contract(s) - %d notified, %d skipped, %d failed.', count( $due_rows ), $notified, $skipped, $failed ),
+ array( 'source' => self::LOG_SOURCE )
+ );
+ }
+
+ return $notified;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
deleted file mode 100644
index 40854371bef..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
+++ /dev/null
@@ -1,197 +0,0 @@
-<?php
-/**
- * CapabilityRegistry - the WordPress-facing gateway capability entry point.
- *
- * Payment gateways declare their subscription capabilities here (the engine's
- * counterpart to WooCommerce's feature-compatibility declarations), and the
- * engine reads the declarations to gate behavior such as renewal scheduling and
- * payment-method change. This class is the public, gateway-author-facing
- * surface; it delegates the actual storage to the WordPress-free Core class
- * {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities}
- * and adds the live-gateway lookup, the override filter, and the ready-hook
- * wiring on top.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway;
-
-use WC_Order;
-use WC_Payment_Gateway;
-use WC_Payment_Gateways;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Public capability registry - gateway-author-facing.
- */
-final class CapabilityRegistry {
-
- /**
- * Filter that always has the final word on a capability check.
- *
- * Receives the result of the static-declaration and live-gateway steps plus
- * the optional order context, and returns the final value. Runtime-injection
- * gateways (where capability resolution depends on the merchant account
- * routing the order) flip a capability on or off here per order.
- */
- public const CAPABILITY_CHECK_FILTER = 'woocommerce_subscriptions_engine_gateway_capability_check';
-
- /**
- * Action fired once capability resolution is stable for the request.
- *
- * Gateways declare in the canonical declaration window
- * (`before_woocommerce_init`); WooCommerce finishes loading afterwards.
- * Consumers that resolve capabilities should wait for this action so the
- * live-gateway step has a populated registry to read.
- */
- public const CAPABILITIES_READY_ACTION = 'woocommerce_subscriptions_engine_capabilities_ready';
-
- /**
- * `woocommerce_loaded` priority for the ready dispatch.
- *
- * Set after the priority used by gateway integration frameworks that inject
- * capability flags onto the live gateway instance, so the live-gateway step
- * sees those flags by the time the ready action fires.
- */
- public const READY_HOOK_PRIORITY = 20;
-
- /**
- * Whether hooks have already been registered, to keep registration
- * idempotent when more than one consumer boots the engine in one request.
- *
- * @var bool
- */
- private static $initialized = false;
-
- /**
- * Wire the ready dispatch onto `woocommerce_loaded`.
- *
- * Idempotent: safe to call from more than one consumer's boot.
- */
- public static function init(): void {
- if ( self::$initialized ) {
- return;
- }
-
- self::$initialized = true;
-
- add_action(
- 'woocommerce_loaded',
- static function (): void {
- do_action( self::CAPABILITIES_READY_ACTION );
- },
- self::READY_HOOK_PRIORITY
- );
- }
-
- /**
- * Register a gateway's declared capabilities.
- *
- * Call from a gateway's `before_woocommerce_init` hook. Each entry must be
- * one of the flag constants on {@see GatewayCapabilities}; an unknown flag
- * throws there so a typo surfaces at registration. Re-declaration replaces.
- *
- * @param string $gateway_id Gateway identifier (matches `WC_Payment_Gateway::$id`).
- * @param array<int, string> $capabilities Capability flag strings.
- */
- public static function declare_compatibility( string $gateway_id, array $capabilities ): void {
- GatewayCapabilities::declare( $gateway_id, $capabilities );
- }
-
- /**
- * Whether `$gateway_id` supports `$capability`.
- *
- * Resolution chain:
- *
- * 1. Static declarations via {@see GatewayCapabilities::is_declared()} - the
- * most explicit signal: the gateway author told us up front.
- * 2. The live gateway instance's `$supports` array, for frameworks that
- * inject capability flags onto the instance during their own init rather
- * than at class-load time. Skipped silently when WooCommerce is not
- * loaded yet or the gateway is not in the registry.
- * 3. The {@see self::CAPABILITY_CHECK_FILTER} filter, which is always the
- * final word: it receives the steps-1-2 result and the `$context` order,
- * and a filter returning false overrides an earlier true, and vice versa.
- * 4. Cast to bool.
- *
- * Returns false (does not throw) for unknown gateway ids and unknown
- * capability strings - this is a predicate, not a validation path.
- *
- * @param string $gateway_id Gateway identifier.
- * @param string $capability Capability flag.
- * @param WC_Order|null $context Optional order for per-order resolution.
- */
- public static function supports( string $gateway_id, string $capability, ?WC_Order $context = null ): bool {
- // Step 1: static declarations.
- $current = GatewayCapabilities::is_declared( $gateway_id, $capability );
-
- // Step 2: live gateway instance `$supports` array. Only consult it when
- // step 1 has not already resolved true - the filter still gets a chance
- // to flip the answer regardless.
- if ( ! $current ) {
- $current = self::gateway_instance_supports( $gateway_id, $capability );
- }
-
- /**
- * Filters the resolved capability check before `supports()` returns.
- *
- * The final word: a filter returning false overrides a true from the
- * static-declaration or live-gateway steps, and vice versa.
- *
- * @param bool $current Result of the static-declaration and live-gateway steps.
- * @param string $gateway_id Gateway being checked.
- * @param string $capability Capability being checked.
- * @param WC_Order|null $context Per-order context, or null for gateway-global checks.
- */
- $resolved = apply_filters( self::CAPABILITY_CHECK_FILTER, $current, $gateway_id, $capability, $context );
-
- return (bool) $resolved;
- }
-
- /**
- * Read the live gateway instance's `$supports` array (resolution step 2).
- *
- * WooCommerce initializes its gateway registry on `woocommerce_loaded`, so
- * the lookup is empty for callers running before that fires. That is not an
- * error: "WooCommerce is not loaded yet" reduces cleanly to "the gateway
- * does not claim that capability via the instance path."
- *
- * @param string $gateway_id Gateway identifier.
- * @param string $capability Capability flag.
- */
- private static function gateway_instance_supports( string $gateway_id, string $capability ): bool {
- if ( ! function_exists( 'WC' ) ) {
- return false;
- }
-
- $wc = WC();
- if ( ! is_object( $wc ) || ! method_exists( $wc, 'payment_gateways' ) ) {
- return false;
- }
-
- $registry = $wc->payment_gateways();
- if ( ! $registry instanceof WC_Payment_Gateways ) {
- return false;
- }
-
- // `payment_gateways()` returns an associative array keyed by gateway id;
- // it is empty until the gateway registry initializes.
- $gateways = $registry->payment_gateways();
- $gateway = $gateways[ $gateway_id ] ?? null;
-
- if ( ! $gateway instanceof WC_Payment_Gateway ) {
- return false;
- }
-
- // `$supports` is documented as an array in core; defensively narrow
- // non-array values so a misconfigured gateway does not throw out of
- // `in_array`.
- $supports = is_array( $gateway->supports ) ? $gateway->supports : array();
-
- return in_array( $capability, $supports, true );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
index 85b52844e84..1c270a25d68 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
@@ -3,14 +3,12 @@
* ConsumerRegistry - the set of extensions consuming engine functionality.
*
* A consumer extension registers its slug here on load, identifying itself as an
- * active owner of the primitives it drives. Engine components read the registry to
- * decide whether, and on whose behalf, to act: an empty registry means "no consumer
- * present," so the engine stays inert when bundled but unused - the renewal
- * dispatcher charges nothing, and future components gate the same way.
+ * owner of the primitives it drives, with an optional configuration. The due
+ * dispatcher only notifies owners that registered a `contract_due_handler`, so the
+ * engine stays inert when bundled but unused.
*
* Static (not instance state) because registration is a load-time, by-class-name
- * call - every consumer reaches the registry by class name, mirroring
- * {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities}.
+ * call - every consumer reaches the registry by class name.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership
*/
@@ -19,6 +17,9 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -27,58 +28,102 @@ defined( 'ABSPATH' ) || exit;
final class ConsumerRegistry {
/**
- * Registered consumer slugs, de-duplicated (a set keyed by slug).
+ * Configuration keys a consumer may register.
+ */
+ private const CONFIG_KEYS = array(
+ 'contract_due_handler' => true,
+ );
+
+ /**
+ * Registered consumers keyed by slug, each with its validated configuration.
*
- * @var array<string, true>
+ * @var array<string, array{contract_due_handler?: callable}>
*/
- private static $slugs = array();
+ private static $consumers = array();
/**
- * Register a consumer extension by its slug.
+ * Register a consumer extension by its slug, with an optional configuration.
*
- * Idempotent: registering the same slug twice is a no-op. The slug is trimmed, and
- * an empty or whitespace-only slug is ignored so a misconfigured caller cannot flip
- * the gate open with a blank registration.
+ * `contract_due_handler`: `callable( ContractView $contract, array $context ): void`, called
+ * when one of the consumer's contracts is due (`$context['due_at']` is the due moment, UTC).
+ * The slug is trimmed; an empty slug is ignored. A repeat registration, an unknown key or a
+ * non-callable handler raises a `_doing_it_wrong()` notice (the first registration is kept;
+ * the invalid key is dropped and the slug still registers). Register by `init`; a handler
+ * registered after `wp_loaded` schedules the recurring due scan right away.
*
- * @param string $slug The consumer extension's registered slug.
+ * @param string $slug The consumer extension's registered slug.
+ * @param array<string, mixed> $config Consumer configuration.
*/
- public static function register( string $slug ): void {
+ public static function register( string $slug, array $config = array() ): void {
$slug = trim( $slug );
if ( '' === $slug ) {
return;
}
- self::$slugs[ $slug ] = true;
+ if ( isset( self::$consumers[ $slug ] ) ) {
+ self::reject( sprintf( 'Consumer "%s" is already registered; the first registration is kept.', $slug ) );
+ return;
+ }
+
+ $filtered_config = ArgumentValidator::filter_known_keys( __METHOD__, $config, self::CONFIG_KEYS );
+ $registration = array();
+
+ if ( array_key_exists( 'contract_due_handler', $filtered_config ) ) {
+ $handler = $filtered_config['contract_due_handler'];
+ if ( is_callable( $handler ) ) {
+ $registration['contract_due_handler'] = $handler;
+ } else {
+ self::reject( sprintf( 'The "contract_due_handler" of consumer "%s" must be callable; it is ignored.', $slug ) );
+ }
+ }
+
+ self::$consumers[ $slug ] = $registration;
+
+ if ( isset( $registration['contract_due_handler'] ) && did_action( 'wp_loaded' ) ) {
+ DueDispatcher::ensure_scheduled();
+ }
}
/**
- * Remove a consumer registration. A deactivating consumer deregisters itself so the
- * engine's gates re-evaluate while its code is still loaded - the engine only loads
- * through its consumers, so the deactivation request is the last chance for components
- * to clean up (e.g. the renewal dispatcher removes its recurring scan when the last
- * consumer leaves). An unknown slug is a no-op.
+ * Remove a consumer registration. A deactivating consumer deregisters itself while its code
+ * is still loaded; when it had a due handler, the due dispatcher re-checks its recurring scan
+ * right away and removes it once no owner has a handler. An unknown slug is a no-op.
*
* @param string $slug The consumer extension's registered slug.
*/
public static function unregister( string $slug ): void {
- unset( self::$slugs[ trim( $slug ) ] );
+ $slug = trim( $slug );
+ $had_handler = isset( self::$consumers[ $slug ]['contract_due_handler'] );
+ unset( self::$consumers[ $slug ] );
+
+ if ( $had_handler ) {
+ DueDispatcher::ensure_scheduled();
+ }
}
/**
- * Whether no consumer is registered. The dispatcher gate: true means charge
- * nothing this run.
+ * The contract due handler a consumer registered, or null when it has none or is not registered.
+ *
+ * @param string $slug The consumer extension's registered slug.
*/
- public static function is_empty(): bool {
- return array() === self::$slugs;
+ public static function get_contract_due_handler( string $slug ): ?callable {
+ return self::$consumers[ trim( $slug ) ]['contract_due_handler'] ?? null;
}
/**
- * The registered consumer slugs (order not significant).
+ * The slugs of the consumers that registered a contract due handler (order not significant).
*
* @return array<int, string>
*/
- public static function all(): array {
- return array_keys( self::$slugs );
+ public static function get_slugs_with_contract_due_handler(): array {
+ $slugs = array();
+ foreach ( self::$consumers as $slug => $registration ) {
+ if ( isset( $registration['contract_due_handler'] ) ) {
+ $slugs[] = (string) $slug;
+ }
+ }
+
+ return $slugs;
}
/**
@@ -88,6 +133,15 @@ final class ConsumerRegistry {
* of the consumer API.
*/
public static function reset(): void {
- self::$slugs = array();
+ self::$consumers = array();
+ }
+
+ /**
+ * Raise the `_doing_it_wrong()` notice for an invalid registration.
+ *
+ * @param string $message What is wrong.
+ */
+ private static function reject( string $message ): void {
+ _doing_it_wrong( __CLASS__ . '::register', esc_html( $message ), '0.0.1' );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
deleted file mode 100644
index 4613a47c753..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
+++ /dev/null
@@ -1,296 +0,0 @@
-<?php
-/**
- * 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. 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
- * (a slow tick overlapping the next) bills at most once.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use Throwable;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Batch renewal dispatcher.
- */
-final class RenewalDispatcher {
-
- /**
- * Action Scheduler hook fired on each recurring scan tick.
- *
- * Public so tooling and tests can inspect, run, or cancel the recurring action via
- * `as_next_scheduled_action()` and friends.
- */
- public const HOOK = 'woocommerce_subscriptions_engine_dispatch_due_renewals';
-
- /**
- * Action Scheduler group - used for admin filterability (Tools -> Scheduled Actions)
- * and bulk teardown. Shared with the rest of the engine's actions.
- */
- public const GROUP = 'woocommerce_subscriptions_engine';
-
- /**
- * Default scan cadence, in seconds (every 10 minutes). Frequent enough that a due
- * renewal fires close to its moment without scanning so often it churns the index.
- */
- private const INTERVAL_SECONDS = 600;
-
- /**
- * Contracts processed per tick. Bounds the work (and the open order/charge volume)
- * of a single run; a backlog drains over successive ticks.
- */
- private const BATCH_SIZE = 50;
-
- /**
- * Logger source tag.
- */
- private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
-
- /**
- * Option holding the next moment the recurring action is re-verified against the Action
- * Scheduler store. Autoloaded (bulk-loaded, effectively free per request), so the common
- * path skips the AS store query {@see self::is_scheduled()} would otherwise run every load.
- */
- private const SCHEDULE_CHECK_OPTION = 'woocommerce_subscriptions_engine_dispatch_scheduled_check';
-
- /**
- * How long a positive schedule check is trusted before re-verifying, in seconds. Bounds the
- * staleness if the recurring action is ever cleared externally: the next check past this
- * window re-creates it, so the dispatcher self-heals rather than stopping silently.
- */
- private const SCHEDULE_RECHECK_SECONDS = 3600;
-
- /**
- * Repository used to scan the contract due-index.
- *
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * The money-path the scan drives per due contract.
- *
- * @var RenewalEngine
- */
- private $engine;
-
- /**
- * The read-only selector that turns a due scan row into the cycle to bill.
- *
- * @var RenewalSelector
- */
- private $selector;
-
- /**
- * Build a dispatcher over the given collaborators.
- *
- * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
- * @param RenewalEngine|null $engine Renewal engine; default instance when omitted.
- * @param RenewalSelector|null $selector Cycle selector; default instance when omitted.
- */
- public function __construct( ?ContractRepository $contracts = null, ?RenewalEngine $engine = null, ?RenewalSelector $selector = null ) {
- $this->contracts = $contracts ?? new ContractRepository();
- $this->engine = $engine ?? new RenewalEngine();
- $this->selector = $selector ?? new RenewalSelector();
- }
-
- /**
- * Register the scan-tick handler on THIS instance.
- *
- * Must run on every boot (not just activation) so Action Scheduler can dispatch a tick
- * back into {@see self::handle_tick()}. A plain `add_action`, safe to call before Action
- * Scheduler has loaded; the recurring action itself is enqueued later via
- * {@see self::ensure_scheduled()}. Instance-based (not static) so the boot-built
- * dispatcher - with whatever collaborators it was constructed over - is the one the
- * tick runs.
- */
- public function register_hooks(): void {
- add_action( self::HOOK, array( $this, 'handle_tick' ) );
- }
-
- /**
- * 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). 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()`
- * fast-path plus a best-effort store-level dedup.
- */
- public static function ensure_scheduled(): void {
- // No consumer, no scan - and a store that previously scheduled the recurring action
- // removes it here, so the job does not keep ticking against the gate after every
- // consumer deactivates. The check option doubles as the "scheduled before" marker,
- // keeping the common no-consumer path to a single autoloaded option read; nothing is
- // re-cached, so a consumer registering later schedules promptly.
- if ( ConsumerRegistry::is_empty() ) {
- if ( false !== get_option( self::SCHEDULE_CHECK_OPTION, false ) ) {
- as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
- delete_option( self::SCHEDULE_CHECK_OPTION );
- }
- return;
- }
-
- // Skip the Action Scheduler store query while a recent positive check is still trusted.
- $next_check = get_option( self::SCHEDULE_CHECK_OPTION, 0 );
- if ( is_numeric( $next_check ) && time() < (int) $next_check ) {
- return;
- }
-
- if ( self::is_scheduled() ) {
- update_option( self::SCHEDULE_CHECK_OPTION, time() + self::SCHEDULE_RECHECK_SECONDS, true );
- return;
- }
-
- // $unique = true is a best-effort store-level dedup: Action Scheduler checks for an
- // existing pending/running action before inserting, but that is not an atomic unique
- // constraint, so two concurrent first-boots could still create two rows. The downstream
- // create-as-claim cycle UNIQUE prevents any double-charge regardless; at worst a duplicate
- // recurring row means redundant scan work until it is cleared. With the is_scheduled()
- // fast-path this keeps the common case to a single recurring action.
- as_schedule_recurring_action(
- time() + self::INTERVAL_SECONDS,
- self::INTERVAL_SECONDS,
- self::HOOK,
- array(),
- self::GROUP,
- true
- );
-
- update_option( self::SCHEDULE_CHECK_OPTION, time() + self::SCHEDULE_RECHECK_SECONDS, true );
- }
-
- /**
- * Whether the recurring scan action is currently scheduled. Private - it queries the
- * Action Scheduler store, so per-request callers must go through the option-cached
- * {@see self::ensure_scheduled()} instead.
- */
- private static function is_scheduled(): bool {
- return false !== as_next_scheduled_action( self::HOOK, array(), self::GROUP );
- }
-
- /**
- * Triggers a batch run - the Action Scheduler dispatch entry point, fired once per tick.
- *
- * A thin wrapper around {@see self::run_batch()}, needed (vs registering that method
- * directly) because a stray `do_action( self::HOOK, ... )` carrying arguments would
- * reach run_batch's typed parameters and fatal; the argument-less wrapper absorbs
- * whatever the hook carries.
- */
- public function handle_tick(): void {
- $this->run_batch();
- }
-
- /**
- * Run one scan tick over up to `$limit` due contracts.
- *
- * 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.
- *
- * @param DateTimeImmutable|null $now The scan moment; defaults to now (UTC). Injectable for tests.
- * @param int $limit Maximum due contracts to process this tick; defaults to
- * the batch size. A non-positive limit is a no-op returning 0.
- * @return int The number of actionable due contracts scanned this tick (0 when gated).
- * A billed/skipped/failed breakdown is logged at debug level.
- */
- public function run_batch( ?DateTimeImmutable $now = null, int $limit = self::BATCH_SIZE ): int {
- if ( $limit < 1 ) {
- return 0;
- }
-
- // 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 )
- );
- return 0;
- }
-
- $now = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
- $candidates = $this->contracts->find_due( $now, $limit, $owners );
-
- $billed = 0;
- $skipped = 0;
- $failed = 0;
-
- foreach ( $candidates as $candidate ) {
- try {
- $cycle_count = $this->selector->select_scheduled_cycle( $candidate, $now );
- if ( null === $cycle_count ) {
- ++$skipped;
- continue;
- }
- $renewal_intent = new RenewalIntent( $candidate->get_contract_id(), $cycle_count );
- if ( null === $this->engine->process( $renewal_intent, $now ) ) {
- // A skip or idempotent no-op (a live claim, an already-settled cycle).
- ++$skipped;
- } else {
- ++$billed;
- }
- } catch ( RenewalNotProcessable $e ) {
- ++$failed;
- // Pre-flight impossibility (e.g. an unresolvable plan): park so the contract
- // leaves the due window and cannot re-poison the scan; a repair re-arms it.
- $this->engine->park( $candidate->get_contract_id() );
- wc_get_logger()->warning(
- sprintf( 'RenewalDispatcher::run(): parking contract %d - %s', $candidate->get_contract_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $candidate->get_contract_id(),
- )
- );
- } catch ( Throwable $e ) {
- // One contract's failure must not stall the batch (or make AS retry the
- // whole tick forever). Log and continue to the next due contract.
- ++$failed;
- wc_get_logger()->error(
- sprintf( 'RenewalDispatcher::run(): processing contract %d threw: %s', $candidate->get_contract_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $candidate->get_contract_id(),
- )
- );
- }
- }
-
- if ( array() !== $candidates ) {
- wc_get_logger()->debug(
- sprintf( 'RenewalDispatcher::run(): scanned %d candidate(s) - %d billed, %d skipped, %d failed.', count( $candidates ), $billed, $skipped, $failed ),
- array( 'source' => self::LOG_SOURCE )
- );
- }
-
- return count( $candidates );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
deleted file mode 100644
index 14c465279ca..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ /dev/null
@@ -1,1174 +0,0 @@
-<?php
-/**
- * The renewal money-path, in two separated concerns joined by a {@see RenewalIntent}:
- *
- * - Selection ({@see RenewalSelector}, read-only) decides which cycle a contract should
- * bill, and whether it is due at all. The batch {@see RenewalDispatcher} runs it over the
- * cycle-aware due scan; `renew_now()` runs its manual variant for a single contract.
- * - Processing ({@see self::process()}) bills exactly the cycle it is handed: it claims that
- * cycle `pending` (create-as-claim, stamping a crash-recovery lease, or reclaiming a
- * stalled one), reconciles the renewal order AFTER the claim (reuse-or-build, draft-first
- * and linked onto the cycle before the order goes live - so the cycle chain, not the
- * mutable order, is the idempotency gate), charges, and completes.
- *
- * Completion is driven by the renewal order's paid state, not the charge call's return, so
- * synchronous and asynchronous gateways share one path: {@see self::complete_from_order()}
- * runs both as a post-charge reconciliation and from `woocommerce_payment_complete` / the
- * failed transition, and every settlement lands through an atomic status compare-and-set so
- * it happens exactly once. A charge with no terminal outcome yet (an async method awaiting
- * confirmation) settles the cycle `processing`, which the lease never reclaims and the scan
- * never re-selects.
- *
- * The batch dispatcher drives renewals off the due-index; no per-contract Action
- * Scheduler rows exist.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use Throwable;
-use WC_Order;
-use WC_Order_Item_Product;
-use Automattic\WooCommerce\Enums\OrderStatus;
-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\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Renewal engine - select, process, complete.
- */
-final class RenewalEngine {
-
- /**
- * Action fired after a renewal order is created, with `( $renewal_order, $contract )`.
- */
- public const RENEWAL_ORDER_CREATED_ACTION = 'woocommerce_subscriptions_engine_renewal_order_created';
-
- /**
- * Action fired after a renewal cycle is billed and the schedule advanced, with
- * `( $contract, $cycle, $renewal_order )`.
- */
- public const RENEWAL_BILLED_ACTION = 'woocommerce_subscriptions_engine_renewal_billed';
-
- /**
- * Logger source tag.
- */
- protected const LOG_SOURCE = 'woocommerce-subscriptions-engine';
-
- /**
- * Crash-recovery lease window, in seconds. When a cycle is claimed `pending` its
- * `claimed_until` is set this far ahead; a pending cycle still unsettled past that
- * moment is treated as a crashed in-flight charge and is reclaimable on a later run.
- * Generous enough to outlast a normal synchronous charge plus gateway round-trip. It
- * guards only the submit window: an accepted async charge moves the cycle `processing`,
- * which carries no lease and is never reclaimed here.
- */
- private const LEASE_TTL_SECONDS = 900;
-
- /**
- * Repository for loading and persisting contracts, and targeted cycle access.
- *
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * Repository for loading the contract's selling plan (the cadence source).
- *
- * @var PlanRepository
- */
- private $plans;
-
- /**
- * The read-only cycle selector `renew_now()` runs for a single contract.
- *
- * @var RenewalSelector
- */
- private $selector;
-
- /**
- * Build a renewal engine over the given collaborators.
- *
- * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
- * @param PlanRepository|null $plans Plan repository; default instance when omitted.
- * @param RenewalSelector|null $selector Cycle selector; default instance when omitted.
- */
- public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null, ?RenewalSelector $selector = null ) {
- $this->contracts = $contracts ?? new ContractRepository();
- $this->plans = $plans ?? new PlanRepository();
- $this->selector = $selector ?? new RenewalSelector();
- }
-
- /**
- * Register the order-driven completion listeners on THIS instance. Must run on every page
- * load so a renewal order reaching a terminal state completes its cycle through
- * {@see self::handle_order_settled()}. Instance-based (not static) so the boot-built
- * engine - with whatever collaborators it was constructed over - is the one the listeners
- * run.
- */
- public function register_hooks(): void {
- add_action( 'woocommerce_payment_complete', array( $this, 'handle_order_settled' ), 10, 1 );
- add_action( 'woocommerce_order_status_failed', array( $this, 'handle_order_settled' ), 10, 1 );
-
- // payment_complete() never fires for a renewal settled by hand - an admin marking a
- // cash-on-delivery-style order processing/completed. Listen to the paid-status
- // transitions too; the CAS settle keeps the double-fire (payment_complete plus its
- // own status transition) idempotent.
- foreach ( wc_get_is_paid_statuses() as $paid_status ) {
- add_action( 'woocommerce_order_status_' . $paid_status, array( $this, 'handle_order_settled' ), 10, 1 );
- }
- }
-
- /**
- * Completion listener - fires when a renewal order reaches a paid or failed state, and
- * settles the matching cycle from that state. The mapping and idempotency live in
- * {@see self::complete_from_order()}. A non-renewal order is ignored there.
- *
- * @param int $order_id The order whose state changed.
- */
- public function handle_order_settled( int $order_id ): void {
- $order = wc_get_order( $order_id );
- if ( $order instanceof WC_Order ) {
- $this->complete_from_order( $order );
- }
- }
-
- /**
- * Renew a contract now at an admin's request, regardless of the schedule. Selection is by head
- * state without the scheduled due-guard ({@see RenewalSelector::select_manual_cycle()}): a
- * settled head is force-advanced to the next cycle (whose period continues from the previous
- * end, so the schedule is preserved, not reset), while a failed or stalled head is re-attempted
- * at its own count. Unlike the scheduled path it never parks the contract - a manual action
- * should not clear the schedule when it cannot proceed.
- *
- * @param int $contract_id The contract to renew.
- * @param DateTimeImmutable|null $now The processing moment; defaults to now (UTC).
- * @return WC_Order|null The renewal order, or null when the contract is not currently renewable.
- */
- public function renew_now( int $contract_id, ?DateTimeImmutable $now = null ): ?WC_Order {
- $now = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
-
- $head = $this->contracts->find_chain_head( $contract_id );
- if ( null === $head ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::renew_now(): contract %d has no billing chain to renew.', $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- return null;
- }
-
- $cycle_count = $this->selector->select_manual_cycle( RenewalCandidate::from_cycle( $head ) );
- if ( null === $cycle_count ) {
- return null;
- }
-
- try {
- return $this->process( new RenewalIntent( $contract_id, $cycle_count ), $now );
- } catch ( RenewalNotProcessable $e ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::renew_now(): cannot renew contract %d. %s', $contract_id, $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- return null;
- }
- }
-
- /**
- * Bill the cycle named by `$intent` - the trigger-agnostic processing primitive.
- *
- * It owns no "which cycle" or "is it due" policy: selection (scheduled, admin, or early
- * renewal) decides the target elsewhere and hands it in, so one primitive serves every
- * trigger and a caller can force a renewal the scheduled guard would otherwise defer.
- *
- * 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 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
- * cycle chain - not the mutable order - is the idempotency authority.
- *
- * Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no currency, no
- * chain, an unresolvable plan, a non-adjacent count, a gateway that cannot charge
- * renewals) so the 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). A contract without a customer renews as a guest order.
- *
- * @param RenewalIntent $intent The contract and cycle count to bill.
- * @param DateTimeImmutable $now The processing moment (the lease clock for a claim).
- * @return WC_Order|null The renewal order, or null when skipped/idempotent.
- * @throws RenewalNotProcessable When the renewal cannot start at all.
- */
- public function process( RenewalIntent $intent, DateTimeImmutable $now ): ?WC_Order {
- $contract_id = $intent->get_contract_id();
- $cycle_count = $intent->get_cycle_count();
-
- $contract = $this->contracts->find( $contract_id );
- if ( null === $contract ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::process(): unknown contract %d - skipping (stale scheduled action).', $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- return null;
- }
-
- if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::process(): contract %d is gateway-scheduled - skipping. The gateway owns the renewal; this primitive row should not have fired.', $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- 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() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- 'status' => $contract->get_status(),
- )
- );
- return null;
- }
-
- // A renewal input an extension may not have supplied yet: without it no order can
- // be built, so the scheduled caller parks the contract out of the due set. A null
- // customer is not one: the renewal order is built as a guest order.
- if ( null === $contract->get_currency() ) {
- throw new RenewalNotProcessable( 'the contract has no currency' );
- }
-
- // Pre-flight capability gate, ahead of the claim so an unchargeable renewal never
- // claims a cycle or creates an order. Without it the charge hook would fire into
- // nothing and the cycle would park `processing` - a stall that misreads as an
- // in-flight charge. Every attempt is futile until the payment method is updated, so
- // the throw lets the scheduled caller park the contract out of the due set.
- $gateway_id = $contract->get_payment_instrument()->get_gateway();
- if ( null === $gateway_id || '' === $gateway_id ) {
- throw new RenewalNotProcessable( 'the contract has no payment gateway to charge renewals with' );
- }
- if ( ! CapabilityRegistry::supports( (string) $gateway_id, GatewayCapabilities::RECURRING ) ) {
- throw new RenewalNotProcessable(
- esc_html( sprintf( 'gateway "%s" does not declare the "recurring" capability - unchargeable until the payment method is updated.', $gateway_id ) )
- );
- }
-
- $head = $this->contracts->find_chain_head( $contract_id );
- if ( null === $head ) {
- throw new RenewalNotProcessable( 'no billing chain to advance' );
- }
-
- $head_count = $head->get_count();
- if ( null === $head_count ) {
- throw new RenewalNotProcessable( esc_html( sprintf( 'head cycle %d has no count to advance from', (int) $head->get_id() ) ) );
- }
-
- // Claim the target cycle - the authoritative idempotency gate, ahead of any order lookup.
- $reclaimed = false;
- if ( $cycle_count === $head_count + 1 ) {
- $cycle = $this->claim_advance( $contract, $head, $cycle_count, $now );
- if ( null === $cycle ) {
- // The append collided: this number was already claimed by an earlier or
- // concurrent run. Take over a stalled claim, or skip a live one.
- $cycle = $this->reclaim_head( $contract_id, $cycle_count, $now );
- $reclaimed = null !== $cycle;
- }
- } elseif ( $cycle_count === $head_count ) {
- $cycle = $this->reclaim_head( $contract_id, $cycle_count, $now );
- $reclaimed = null !== $cycle;
- } else {
- throw new RenewalNotProcessable(
- esc_html( sprintf( 'cycle %d is not adjacent to head cycle %d - refusing to bill a gap.', $cycle_count, $head_count ) )
- );
- }
-
- if ( null === $cycle ) {
- return null;
- }
-
- // Reconcile the order AFTER the claim: reuse the one linked or tagged for this cycle, or
- // build one. The cycle being settled is the price + period authority; a reclaimed cycle
- // carries its OWN stored total, so the order bills that, never a freshly-computed next
- // period. A cycle appended by THIS run cannot have an order yet - order work strictly
- // follows the claim - so the lookup (and its meta scan) runs only for a reclaimed cycle,
- // where an earlier attempt may have left one.
- $renewal_order = $reclaimed ? $this->find_renewal_order_for_cycle( $cycle ) : null;
- $order_created = false;
- if ( null === $renewal_order ) {
- $renewal_order = $this->build_renewal_order( $contract, $cycle );
- if ( null === $renewal_order ) {
- // build_renewal_order logged the reason. The claimed cycle stays pending for a
- // later run to resolve; no schedule change is made here.
- return null;
- }
- $order_created = true;
- } elseif ( $cycle->get_order_id() !== $renewal_order->get_id() ) {
- // Found via the meta fallback: heal the missing cycle link before the order acts.
- $cycle->set_order_id( $renewal_order->get_id() );
- $this->contracts->update_cycle( $cycle );
- }
-
- // A reused order abandoned mid-creation may still be a draft: promote it before the
- // charge (a draft is not payable).
- if ( $renewal_order->has_status( OrderStatus::CHECKOUT_DRAFT ) ) {
- $renewal_order->set_status( OrderStatus::PENDING );
- $renewal_order->save();
- }
-
- // Charge only when the order is not already paid - a crash after the charge, or a prior
- // async attempt that has since settled, needs no second charge; completion handles it.
- // The order was built or loaded moments ago with no gateway in between, so its own paid
- // state is current.
- if ( ! $renewal_order->is_paid() ) {
- $this->ensure_payment_token( $renewal_order, $contract );
- // The created action fires once, for a genuinely new order only. A reused order - a
- // reclaimed stall resuming an earlier attempt - already announced its creation, so
- // re-firing would double one-time side effects (customer emails, analytics).
- if ( $order_created ) {
- /**
- * Fires after a renewal order is created, before it is charged.
- * Fires immediately after the write, not after a surrounding transaction commits.
- *
- * @param WC_Order $renewal_order The new renewal order.
- * @param Contract $contract The contract being renewed.
- */
- do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
- }
- $this->attempt_charge( $renewal_order, $contract );
- }
-
- // Complete from the order's paid state. Idempotent and re-reading fresh, so a sync
- // gateway that already settled the cycle via the nested payment_complete listener is a
- // no-op here, and an async charge with no terminal outcome yet lands on `processing`.
- $this->complete_from_order( $renewal_order );
-
- return $renewal_order;
- }
-
- /**
- * Resolve the billing policy the next cycle bills under, from the contract's own plan
- * snapshot - the live source of truth, so a contract updated since an earlier cycle bills
- * on its current terms. Falls back to parsing the live selling plan's billing payload when
- * the contract carries no snapshot, or one whose billing policy is absent or unusable (that
- * case is logged), and returns null when neither resolves (a deleted plan, or a live
- * billing payload that is null, does not parse or has no usable cadence; the payload
- * cases are logged) so the caller parks the contract rather than mis-billing or
- * retrying every tick.
- *
- * @param Contract $contract The contract being renewed.
- * @return BillingPolicy|null The billing policy, or null when unresolvable.
- */
- private function resolve_billing_policy( Contract $contract ): ?BillingPolicy {
- $snapshot = $this->resolve_plan_snapshot( $contract );
- if ( $snapshot instanceof PlanSnapshot ) {
- try {
- $policy = $snapshot->read_billing_policy();
- if ( null !== $policy ) {
- return $policy;
- }
- } catch ( \DomainException $e ) {
- // A corrupt stored policy must not crash the scheduled run; fall through to the
- // live plan below so the renewal can still resolve on current terms.
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
- }
- }
-
- $plan_id = $contract->get_selling_plan_id();
- if ( null === $plan_id ) {
- return null;
- }
-
- $plan = $this->plans->find( $plan_id );
- if ( null === $plan ) {
- return null;
- }
-
- $billing = $plan->get_billing_policy();
- if ( null === $billing ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine: contract %d has a live plan %d with no billing policy; the renewal cannot be processed.', (int) $contract->get_id(), (int) $plan_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- 'plan_id' => (int) $plan_id,
- )
- );
-
- return null;
- }
-
- try {
- return BillingPolicy::from_array( $billing );
- } catch ( \DomainException $e ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine: contract %d has an unreadable live plan billing policy; the renewal cannot be processed. %s', (int) $contract->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- 'plan_id' => (int) $plan_id,
- )
- );
-
- return null;
- }
- }
-
- /**
- * The contract's plan snapshot - the frozen terms the policy resolvers read.
- *
- * The full contract reads already carry the snapshot; the store fetch is the
- * fallback for a contract built on a lean path (row-only reads). Null when the
- * contract carries no snapshot at all.
- *
- * @param Contract $contract The contract being renewed.
- */
- private function resolve_plan_snapshot( Contract $contract ): ?PlanSnapshot {
- return $contract->get_plan_snapshot() ?? $this->contracts->find_plan_snapshot( $contract->get_plan_snapshot_id() );
- }
-
- /**
- * Claim the head's successor cycle as the create-as-claim: resolve the cadence, compute the
- * new `pending` cycle one period past the head, stamp a crash-recovery lease, and insert it.
- * Returns ONLY a freshly appended cycle - on a UNIQUE(contract_id, kind, count) collision
- * (another worker already appended this number) it returns null and the caller routes
- * through {@see self::reclaim_head()}. Any other write failure is logged as an error (never
- * mistaken for the benign collision) and also returns null, so the contract is retried on a
- * later tick. Keeping the fresh/reclaimed distinction at the caller lets it skip order
- * lookups a brand-new cycle cannot need.
- *
- * @param Contract $contract The contract being renewed.
- * @param Cycle $head The chain's head cycle (the new cycle's predecessor).
- * @param int $cycle_count The chargeable number to append (the head's successor).
- * @param DateTimeImmutable $now The processing moment (the lease clock).
- * @return Cycle|null The freshly appended pending cycle, or null on an append collision.
- * @throws RenewalNotProcessable When the billing plan cannot be resolved (a deleted plan).
- */
- private function claim_advance( Contract $contract, Cycle $head, int $cycle_count, DateTimeImmutable $now ): ?Cycle {
- $policy = $this->resolve_billing_policy( $contract );
- if ( null === $policy ) {
- throw new RenewalNotProcessable( 'cannot resolve the billing plan (the selling plan may have been deleted)' );
- }
-
- $new_cycle = RenewalCalculator::compute_next_cycle(
- $policy,
- array(
- 'contract_id' => (int) $contract->get_id(),
- 'sequence_no' => $head->get_sequence_no() + 1,
- 'count' => $cycle_count,
- 'period_start' => $head->get_ends_at_gmt(),
- 'expected_total' => $contract->get_billing_total(),
- 'currency' => (string) $contract->get_currency(),
- 'extension_slug' => $contract->get_extension_slug(),
- 'plan_snapshot_id' => $contract->get_plan_snapshot_id(),
- 'items_snapshot_id' => $contract->get_items_snapshot_id(),
- )
- );
- $new_cycle->set_claimed_until_gmt( $this->lease_until( $now ) );
-
- try {
- $this->contracts->append_cycle( $new_cycle, $head );
- } catch ( DuplicateCycleException $e ) {
- // The UNIQUE(contract_id, kind, count) index rejected the row: another worker
- // already appended this number. Null routes the caller to the reclaim path.
- return null;
- } catch ( Throwable $e ) {
- // A real write failure, not the benign collision - surface it instead of
- // mistaking it for a claim race. The contract is retried on a later scan tick.
- wc_get_logger()->error(
- sprintf( 'RenewalEngine::claim_advance(): cannot claim cycle %d for contract %d - will retry on a later scan. %s', $cycle_count, (int) $contract->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
- return null;
- }
-
- return $new_cycle;
- }
-
- /**
- * Reclaim the chain head at `$count` for a re-attempt, or skip. Re-reads the head; two heads
- * are reclaimable, each via an atomic compare-and-set so that among concurrent workers only
- * the one whose UPDATE matches the row wins (the rest match zero rows and skip, so the cycle
- * is charged at most once):
- *
- * - a `pending` cycle whose `claimed_until` lease has expired - a charge that claimed but
- * never settled (crash recovery), via {@see ContractRepository::reclaim_expired_cycle()};
- * - a `failed` cycle - an admin-triggered retry that flips it back to `pending`, via
- * {@see ContractRepository::reclaim_failed_cycle()}. Scheduled selection never routes a
- * failed head here; only a manual trigger does.
- *
- * A still-leased pending cycle (a live claim), a settled cycle, or a `processing` head
- * (awaiting its gateway) is a no-op (null).
- *
- * @param int $contract_id The contract being renewed.
- * @param int $count The chargeable number to reclaim.
- * @param DateTimeImmutable $now The processing moment (the lease clock).
- * @return Cycle|null The reclaimed cycle (this caller won the CAS), or null to skip.
- */
- private function reclaim_head( int $contract_id, int $count, DateTimeImmutable $now ): ?Cycle {
- $head = $this->contracts->find_chain_head( $contract_id );
-
- if ( null === $head || $count !== $head->get_count() ) {
- // The chain moved on (or vanished) between selection and the claim: nothing at
- // this number to reclaim.
- wc_get_logger()->info(
- sprintf( 'RenewalEngine::process(): cycle %d for contract %d is no longer the chain head - skipping.', $count, $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
-
- return null;
- }
-
- 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.
- $won = $this->contracts->reclaim_expired_cycle( (int) $head->get_id(), self::LEASE_TTL_SECONDS );
-
- if ( $won ) {
- wc_get_logger()->info(
- sprintf( 'RenewalEngine::process(): reclaiming stalled cycle %d for contract %d (lease expired) - re-attempting.', $count, $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
-
- return $head;
- }
-
- // Another worker won the reclaim CAS between our read and write: skip.
- wc_get_logger()->info(
- sprintf( 'RenewalEngine::process(): cycle %d for contract %d was reclaimed by another worker - skipping.', $count, $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
-
- return null;
- }
-
- // 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( 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(
- sprintf( 'RenewalEngine::process(): retrying failed cycle %d for contract %d - re-attempting.', $count, $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
-
- return $head;
- }
-
- return null;
- }
-
- // A live lease (concurrent worker), an already-settled cycle, or one awaiting its
- // gateway (`processing`): idempotent no-op.
- wc_get_logger()->info(
- sprintf( 'RenewalEngine::process(): cycle %d for contract %d is already claimed or settled - skipping.', $count, $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
-
- return null;
- }
-
- /**
- * The lease expiry to stamp on a freshly-claimed cycle: `$now` + {@see self::LEASE_TTL_SECONDS},
- * as a GMT string. `$now` is the same processing moment the due-guard uses, so a single
- * `process()` call reads one clock throughout.
- *
- * @param DateTimeImmutable $now The processing moment.
- */
- private function lease_until( DateTimeImmutable $now ): string {
- return gmdate( 'Y-m-d H:i:s', $now->getTimestamp() + self::LEASE_TTL_SECONDS );
- }
-
- /**
- * Whether `$cycle`'s crash-recovery lease has expired (it is reclaimable).
- *
- * Every cycle the engine claims stamps a lease, so an in-flight pending cycle carries
- * one. A cycle with NO lease recorded is treated as NOT expired (not reclaimable): the
- * engine cannot prove it is stale, so it is left as a live claim rather than risk
- * re-charging a cycle some other path created. Only an explicit lease whose moment has
- * passed is reclaimable.
- *
- * A cheap local pre-check only: the reclaim compare-and-set runs its own expiry predicate
- * on the DATABASE clock, so a skewed PHP clock here cannot win a takeover early - at worst
- * it wastes (fast clock) or defers (slow clock) a reclaim attempt by the skew.
- *
- * @param Cycle $cycle The cycle whose lease to test.
- * @param DateTimeImmutable $now The processing moment (the lease clock).
- */
- private function lease_has_expired( Cycle $cycle, DateTimeImmutable $now ): bool {
- $claimed_until = $cycle->get_claimed_until_gmt();
- if ( null === $claimed_until ) {
- return false;
- }
-
- $expires_at = strtotime( $claimed_until . ' UTC' );
-
- // An unparsable lease is treated as live (not reclaimable): never re-charge on bad data.
- return false !== $expires_at && $expires_at <= $now->getTimestamp();
- }
-
- /**
- * Complete a renewal from its order's paid state - the single completion routine, reached
- * as a post-charge reconciliation in {@see self::process()} and from the order-status
- * listener {@see self::handle_order_settled()}. Keying completion on the order (not the
- * charge call's return) lets synchronous and asynchronous gateways share one path.
- *
- * The order's renewal meta locates the contract; whether this order settles the head is
- * then decided by the CYCLE's own data: a head linked to an order settles only from that
- * order, and the order meta count is consulted only for an unlinked head (a pre-link
- * crash). Re-reading the head fresh keeps it idempotent: it acts only while the head is the
- * still-in-flight cycle this order bills (`pending`/`processing`) and no-ops once it is
- * terminal or the chain has advanced. A non-renewal order is ignored.
- *
- * @param WC_Order $order The order whose state may settle a cycle.
- */
- public function complete_from_order( WC_Order $order ): void {
- if ( OrderLinkage::RELATION_RENEWAL !== $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
- return;
- }
-
- $contract_id = Coercion::coerce_int( $order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
- if ( $contract_id <= 0 ) {
- return;
- }
-
- $contract = $this->contracts->find( $contract_id );
- if ( null === $contract ) {
- return;
- }
-
- $cycle = $this->contracts->find_chain_head( $contract_id );
- if ( null === $cycle ) {
- return;
- }
-
- $linked_id = $cycle->get_order_id();
- if ( null !== $linked_id ) {
- // The head knows its order: settle only from that order, whatever the meta says.
- if ( $linked_id !== $order->get_id() ) {
- return;
- }
- } else {
- // Unlinked head (a crash before the link was stamped): fall back to the order's
- // chargeable-number meta to decide whether it bills this head.
- $count_meta = $order->get_meta( self::renewal_cycle_meta_key() );
- if ( ! is_numeric( $count_meta ) || (int) $count_meta !== $cycle->get_count() ) {
- return;
- }
- }
-
- $status = $cycle->get_status()->get_value();
- if ( CycleStatus::PENDING !== $status && CycleStatus::PROCESSING !== $status ) {
- // Already terminal: idempotent no-op (a concurrent path settled it first).
- return;
- }
-
- $this->settle_cycle( $contract, $cycle, $order );
- }
-
- /**
- * Settle an in-flight cycle from `$order`'s paid state, and advance the contract on success.
- *
- * Every outcome lands through {@see ContractRepository::transition_cycle_status()} - an
- * atomic compare-and-set on the status the caller read - so among racing settlers (the
- * post-charge reconciliation and the order-status listener can overlap across workers)
- * exactly one wins each transition, and the billed action fires exactly once per cycle.
- *
- * Paid -> cycle `billed`, `next_payment_gmt` advanced to the cycle's OWN `ends_at_gmt` (the
- * period actually charged, so a reclaimed cycle advances exactly one cadence, never
- * skipping one) and `last_payment_gmt` taken from the order's paid date - inputs that do
- * not move between invocations. Failed -> cycle `failed` (recording a reason), schedule
- * left for a later dunning pass. Neither yet -> cycle `processing`: the gateway accepted an
- * async charge whose outcome will arrive later; the crash-recovery lease is cleared (a
- * submitted charge is no longer a mid-submit window to reclaim) and the schedule is left
- * untouched until the order settles.
- *
- * @param Contract $contract The contract being renewed.
- * @param Cycle $cycle The in-flight (`pending`/`processing`) cycle to settle.
- * @param WC_Order $order The renewal order carrying the outcome.
- */
- private function settle_cycle( Contract $contract, Cycle $cycle, WC_Order $order ): void {
- $now = gmdate( 'Y-m-d H:i:s' );
-
- // Re-fetch the order: a gateway handler that called payment_complete() on its own
- // freshly-loaded instance leaves the passed object stale, which would misread a
- // successful charge. Never settle from the stale copy: when the fresh read fails (the
- // order vanished mid-flight) the cycle stays in flight for a later run to resolve.
- $fresh = wc_get_order( $order->get_id() );
- if ( ! $fresh instanceof WC_Order ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::settle_cycle(): renewal order %d could not be re-read - leaving cycle %d unsettled.', $order->get_id(), (int) $cycle->get_id() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- 'order_id' => $order->get_id(),
- )
- );
- return;
- }
- $order = $fresh;
-
- $cycle_id = (int) $cycle->get_id();
- $read_status = $cycle->get_status()->get_value();
-
- if ( $order->is_paid() ) {
- if ( ! $this->contracts->transition_cycle_status( $cycle_id, $read_status, CycleStatus::BILLED, $order->get_id() ) ) {
- // Another settler won the CAS; its transition carried the side effects.
- return;
- }
- // Sync the entity with the row the CAS just wrote, for the action payload.
- $cycle->set_order_id( $order->get_id() );
- $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;
- // the payment moment comes from the order itself.
- $paid_at = $order->get_date_paid();
- $contract->set_next_payment_gmt( $cycle->get_ends_at_gmt() );
- $contract->set_last_payment_gmt( null !== $paid_at ? gmdate( 'Y-m-d H:i:s', $paid_at->getTimestamp() ) : $now );
- $contract->set_last_attempt_gmt( $now );
- // Conditioned on the ACTIVE status this settle read: a lifecycle transition
- // that landed mid-settle (a customer cancel) must not be clobbered by this
- // schedule advance - the cycle outcome above already landed either way.
- if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE ) ) {
- wc_get_logger()->info(
- sprintf( 'RenewalEngine: contract %d transitioned during settle; schedule advance skipped.', (int) $contract->get_id() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
-
- // The action below announces "billed AND schedule advanced"; the advance
- // did not persist, so do not hand listeners the unpersisted in-memory
- // entity. The order + billed cycle rows carry the charge for audit.
- return;
- }
-
- /**
- * Fires after a renewal cycle is billed and the contract schedule advanced.
- * Fires immediately after the write, not after a surrounding transaction commits.
- *
- * @param Contract $contract The renewed contract.
- * @param Cycle $cycle The newly-billed cycle.
- * @param WC_Order $order The paid renewal order.
- */
- do_action( self::RENEWAL_BILLED_ACTION, $contract, $cycle, $order );
-
- return;
- }
-
- if ( $order->has_status( OrderStatus::FAILED ) ) {
- if ( ! $this->contracts->transition_cycle_status( $cycle_id, $read_status, CycleStatus::FAILED, $order->get_id(), 'gateway-charge-failed' ) ) {
- return;
- }
- $cycle->set_order_id( $order->get_id() );
- $cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
- $cycle->set_reason( 'gateway-charge-failed' );
- $cycle->set_claimed_until_gmt( null );
-
- $contract->set_last_attempt_gmt( $now );
- // Conditioned like the billed branch: bookkeeping only, never worth
- // clobbering a concurrent lifecycle transition for.
- $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE );
-
- return;
- }
-
- // Neither paid nor failed: the gateway accepted the charge but has not confirmed it
- // (an async method). Park the cycle in `processing` until its outcome arrives; the
- // listener completes it then. Only a pending cycle needs the write - a processing one
- // re-entering here is already parked.
- if ( CycleStatus::PENDING === $read_status ) {
- $this->contracts->transition_cycle_status( $cycle_id, CycleStatus::PENDING, CycleStatus::PROCESSING, $order->get_id() );
- }
-
- $contract->set_last_attempt_gmt( $now );
- // Conditioned like the billed branch: bookkeeping only, never worth clobbering
- // a concurrent lifecycle transition for.
- $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE );
- }
-
- /**
- * Build the renewal order for `$cycle` from the contract's own stored state: its billing /
- * shipping addresses and its (recurring) line items - never the origin order, whose cart may
- * have carried one-time items that must not ride along onto a renewal. Applies the cycle's
- * expected total as ground truth, attaches the contract's payment token, and tags the
- * renewal relation meta (contract id + chargeable number) so charge observers and the
- * order-to-cycle mapping can find it.
- *
- * Created draft-first: the order starts as `checkout-draft`, is linked onto the claimed
- * cycle (`order_id`), and only then becomes `pending`. A crash mid-way therefore leaves
- * either a linked draft the resume path promotes, or an unlinked draft that fires no emails
- * and is swept by core's stale-draft cleanup - never a live pending order the cycle does not
- * know about. Returns null (logged) when `wc_create_order()` fails.
- *
- * @param Contract $contract Contract being renewed.
- * @param Cycle $cycle The claimed cycle this order bills (count + expected total).
- * @return WC_Order|null The saved pending renewal order, or null on failure.
- */
- private function build_renewal_order( Contract $contract, Cycle $cycle ): ?WC_Order {
- $count = (int) $cycle->get_count();
- $expected_total = $cycle->get_expected_total();
-
- $renewal_order = wc_create_order(
- array(
- 'customer_id' => (int) $contract->get_customer_id(),
- 'status' => OrderStatus::CHECKOUT_DRAFT,
- 'created_via' => 'woocommerce_subscriptions_engine_renewal',
- )
- );
-
- if ( is_wp_error( $renewal_order ) ) {
- wc_get_logger()->error(
- sprintf( 'RenewalEngine: wc_create_order() failed for contract %d: %s', (int) $contract->get_id(), $renewal_order->get_error_message() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
- return null;
- }
-
- $instrument = $contract->get_payment_instrument();
-
- $renewal_order->set_currency( (string) $contract->get_currency() );
- if ( null !== $instrument->get_gateway() ) {
- $renewal_order->set_payment_method( (string) $instrument->get_gateway() );
- }
- if ( null !== $instrument->get_title() ) {
- $renewal_order->set_payment_method_title( (string) $instrument->get_title() );
- }
-
- // Addresses come from the contract (its live source of truth), not the origin order. The
- // array setters only hydrate the order in memory (persisted by the save() below), unlike
- // the legacy set_address() which writes post meta directly.
- $addresses = $contract->get_addresses();
- if ( isset( $addresses['billing'] ) && is_array( $addresses['billing'] ) ) {
- $renewal_order->set_billing_address( $addresses['billing'] );
- }
- if ( isset( $addresses['shipping'] ) && is_array( $addresses['shipping'] ) ) {
- $renewal_order->set_shipping_address( $addresses['shipping'] );
- }
-
- // Only the contract's recurring line items - the origin order's one-time cart items are
- // deliberately excluded so a mixed checkout cannot leak onto a renewal. A line for a
- // since-deleted product makes WC_Order_Item_Product::set_product_id() throw; treat the
- // whole build as a recoverable skip (logged, null) rather than let it reach the scheduler
- // as a permanent failure that retries forever.
- try {
- foreach ( $contract->get_items() as $item ) {
- $line = new WC_Order_Item_Product();
- $line->set_name( self::item_string( $item, 'item_name' ) );
- $line->set_product_id( self::item_int( $item, 'product_id' ) );
- $line->set_variation_id( self::item_int( $item, 'variation_id' ) );
- // Stored quantity as is (zero and fractional included); the woocommerce_stock_amount
- // filter decides precision.
- $line->set_quantity( self::item_string( $item, 'quantity' ) ); // @phpstan-ignore argument.type (docblock-only int; fractional quantities are valid)
- $line->set_subtotal( self::item_string( $item, 'subtotal' ) );
- $line->set_total( self::item_string( $item, 'total' ) );
- $renewal_order->add_item( $line );
- }
- } catch ( Throwable $e ) {
- wc_get_logger()->error(
- sprintf( 'RenewalEngine: cannot build renewal items for contract %d (a product may have been deleted): %s', (int) $contract->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
- return null;
- }
-
- // The new cycle's expected_total is the price authority - applied after add_item() so
- // the line items do not recompute over it. Reconstructing the granular discount /
- // shipping / tax breakdown is a later money-path's job.
- $renewal_order->set_total( $expected_total );
-
- // Tag the renewal relation + chargeable number so completion can map the order back to
- // its cycle, and save (still a draft) before any linking so a crash between the two
- // leaves the order findable (no duplicate charge on the retry).
- $renewal_order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract->get_id() );
- $renewal_order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
- $renewal_order->update_meta_data( self::renewal_cycle_meta_key(), (string) $count );
- $renewal_order->save();
-
- // Link the order onto the claimed cycle BEFORE the order goes live: the cycle row is the
- // idempotency authority, so the link must exist by the time the order can act (emails,
- // charges). Only then does the draft become a real pending order.
- $cycle->set_order_id( $renewal_order->get_id() );
- $this->contracts->update_cycle( $cycle );
-
- $renewal_order->set_status( OrderStatus::PENDING );
- $renewal_order->save();
-
- $this->ensure_payment_token( $renewal_order, $contract );
-
- return $renewal_order;
- }
-
- /**
- * Attach the contract's stored payment token to `$order` when it carries none. Idempotent:
- * a no-op when the order already has a token, so it is safe both on a freshly-built order
- * and when resuming a renewal order a crash may have left un-tokenised before its charge.
- *
- * @param WC_Order $order The renewal order to tokenise.
- * @param Contract $contract The contract whose payment instrument holds the token.
- */
- private function ensure_payment_token( WC_Order $order, Contract $contract ): void {
- if ( array() !== $order->get_payment_tokens() ) {
- return;
- }
-
- $token_id = $contract->get_payment_instrument()->get_token_id();
- if ( null === $token_id ) {
- return;
- }
-
- $token = \WC_Payment_Tokens::get( $token_id );
- if ( $token instanceof \WC_Payment_Token ) {
- $order->add_payment_token( $token );
- }
- }
-
- /**
- * Read a contract-item field as a string, defaulting to empty when absent or non-scalar.
- *
- * @param array<string, mixed> $item The contract item row.
- * @param string $key Field key.
- */
- private static function item_string( array $item, string $key ): string {
- $value = $item[ $key ] ?? null;
- return is_scalar( $value ) ? (string) $value : '';
- }
-
- /**
- * Read a contract-item field as an int, defaulting to 0 when absent or non-numeric.
- *
- * @param array<string, mixed> $item The contract item row.
- * @param string $key Field key.
- */
- private static function item_int( array $item, string $key ): int {
- $value = $item[ $key ] ?? null;
- return is_numeric( $value ) ? (int) $value : 0;
- }
-
- /**
- * Attempt the gateway charge for `$renewal_order`.
- *
- * Fires `woocommerce_subscriptions_engine_scheduled_payment_{gateway}` so the
- * registered gateway integration captures against the stored token; the engine does
- * not charge itself. A gateway that registers no handler leaves the order `pending`
- * (uncharged) - the safe state when it cannot actually charge.
- *
- * @param WC_Order $renewal_order The pending renewal order to charge.
- * @param Contract $contract The contract being renewed.
- */
- private function attempt_charge( WC_Order $renewal_order, Contract $contract ): void {
- // process() pre-flights the gateway (present + declares `recurring`) before any claim,
- // so the instrument is chargeable by the time the money-path reaches the charge.
- $gateway_id = (string) $contract->get_payment_instrument()->get_gateway();
-
- $amount = (float) $renewal_order->get_total();
-
- try {
- /**
- * Fires to request a recurring charge for a renewal order. The gateway (or its
- * adapter) captures against the stored token, then transitions the order via its
- * own `payment_complete()` / failure handling. The gateway is expected to reach a
- * terminal order state for errors it can detect - mark the order failed on a
- * decline or an unrecoverable processing error. An order left neither paid nor
- * failed is treated as an async charge awaiting confirmation: its cycle parks in
- * `processing` until the order settles (or is resolved manually).
- *
- * @param float $amount The amount to charge.
- * @param WC_Order $renewal_order The renewal order being charged.
- */
- do_action( 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway_id, $amount, $renewal_order );
- } catch ( Throwable $e ) {
- // A throwing gateway handler must not leave the AS action in a retry-forever
- // loop. Log and move on; the order stays pending for dunning to pick up.
- wc_get_logger()->error(
- sprintf( 'RenewalEngine: gateway charge for renewal order %d (contract %d) threw: %s', $renewal_order->get_id(), (int) $contract->get_id(), $e->getMessage() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- 'order_id' => $renewal_order->get_id(),
- )
- );
- }
- }
-
- /**
- * The renewal order for `$cycle`, or null when none exists - the reuse lookup the
- * post-claim order reconciliation runs for a RECLAIMED cycle only. A freshly appended
- * cycle skips it entirely (no order can exist before its claim), which keeps the meta
- * scan below off the every-renewal path.
- *
- * The cycle's own `order_id` reference resolves directly (we already hold the row, and the
- * link is stamped at order creation). The meta search is the fallback for a cycle that was
- * claimed but never linked - a crash between creating the order and linking it: it queries
- * on the contract id via the flat `meta_key` / `meta_value` shortcut, then narrows by
- * relation type and chargeable number in PHP. The flat shortcut is used rather than a
- * `meta_query` because the legacy CPT order store rejects `meta_query` with
- * `wc_doing_it_wrong`; the shortcut round-trips through both stores. Statuses are passed
- * explicitly (not `'any'`) because a crash-abandoned order may still be a `checkout-draft`,
- * which `'any'` excludes.
- *
- * @param Cycle $cycle The claimed cycle whose renewal order to resolve.
- * @return WC_Order|null The existing renewal order for the cycle, or null when none.
- */
- private function find_renewal_order_for_cycle( Cycle $cycle ): ?WC_Order {
- $linked_id = $cycle->get_order_id();
- if ( null !== $linked_id ) {
- $linked = wc_get_order( $linked_id );
- if ( $linked instanceof WC_Order ) {
- return $linked;
- }
- // The linked order is gone (deleted): fall through to the meta search.
- }
-
- $orders = wc_get_orders(
- array(
- 'limit' => -1,
- 'status' => array_keys( wc_get_order_statuses() ),
- 'type' => 'shop_order',
- 'meta_key' => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
- 'meta_value' => (string) $cycle->get_contract_id(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
- )
- );
-
- // Unpaginated, so wc_get_orders() returns a plain list. The guard narrows the
- // declared return type and treats any non-array result as "no matching renewal".
- if ( ! is_array( $orders ) ) {
- return null;
- }
-
- foreach ( $orders as $order ) {
- if ( ! $order instanceof WC_Order ) {
- continue;
- }
-
- if ( OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
- && (string) $cycle->get_count() === $order->get_meta( self::renewal_cycle_meta_key() ) ) {
- return $order;
- }
- }
-
- return null;
- }
-
- /**
- * Park a contract that cannot be auto-renewed by clearing its `next_payment_gmt`, so it
- * leaves the due-index and the scan stops revisiting it every tick - which would otherwise
- * let a cluster of un-renewable contracts hold the front of the oldest-due-first scan and
- * starve healthy renewals. A no-op when the contract is gone. A repair (fixing the
- * underlying data and rescheduling) re-arms it.
- *
- * Best-effort, never throws: parking protects the scan, it is not a correctness requirement,
- * and it runs inside the dispatcher's per-contract error handling - a failure here (the row
- * 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 || ContractStatus::ACTIVE !== $contract->get_status() ) {
- return;
- }
-
- $contract->set_next_payment_gmt( null );
- // 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() ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- }
- }
-
- /**
- * Order meta key recording which cycle a renewal order bills.
- *
- * Read on both sides of the charge: before it, to find and reuse an existing unsettled order
- * for a cycle ({@see self::find_renewal_order_for_cycle()}) rather than build a duplicate;
- * after it, to map the settled order back to its cycle ({@see self::complete_from_order()}).
- * Internal to the engine.
- */
- private static function renewal_cycle_meta_key(): string {
- return '_subscription_renewal_cycle';
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php
deleted file mode 100644
index 1bc5bbd5d78..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-/**
- * RenewalIntent - the seam between selection and processing: a resolved decision to bill a
- * specific cycle of a specific contract. A trigger (the batch {@see RenewalDispatcher} or the
- * admin `renew_now()` path) builds one from the cycle {@see RenewalSelector} chose;
- * {@see RenewalEngine::process()} consumes it.
- *
- * Carrying the target as an explicit value keeps `process()` free of any "which cycle"
- * logic - it bills exactly the count it is handed, so a future trigger (an admin retry, a
- * customer early renewal) is just a different trigger building a different intent over
- * the same processing path.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * A resolved decision to bill one cycle of one contract.
- */
-final class RenewalIntent {
-
- /**
- * Contract to bill.
- *
- * @var int
- */
- private $contract_id;
-
- /**
- * The chargeable cycle count to bill.
- *
- * @var int
- */
- private $cycle_count;
-
- /**
- * Build an intent to bill `$cycle_count` of `$contract_id`.
- *
- * @param int $contract_id Contract to bill.
- * @param int $cycle_count The chargeable cycle count to bill.
- */
- public function __construct( int $contract_id, int $cycle_count ) {
- $this->contract_id = $contract_id;
- $this->cycle_count = $cycle_count;
- }
-
- /**
- * Contract to bill.
- */
- public function get_contract_id(): int {
- return $this->contract_id;
- }
-
- /**
- * The chargeable cycle count to bill.
- */
- public function get_cycle_count(): int {
- return $this->cycle_count;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php
deleted file mode 100644
index 0a336450272..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php
+++ /dev/null
@@ -1,29 +0,0 @@
-<?php
-/**
- * RenewalNotProcessable - a renewal cannot start: a pre-flight impossibility discovered
- * before any charge (no billing chain to advance, or a billing plan that no longer
- * resolves). Thrown by {@see RenewalEngine::process()} so the invoking flow decides the
- * response - the caller (which already holds the contract id) parks the contract, clearing
- * its next payment so it leaves the due window and cannot re-poison the scan every tick; a
- * repair re-arms it.
- *
- * A marker exception: the message carries the reason, the caller carries the contract.
- * Distinct from an ordinary skip (an idempotent no-op returns null, no park) and from a
- * charge that ran and did not settle (that settles the cycle `failed`, not an exception).
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-use RuntimeException;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Thrown when a renewal cannot be processed at all (a park-worthy pre-flight failure).
- */
-final class RenewalNotProcessable extends RuntimeException {
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php
deleted file mode 100644
index 61191e80cf3..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php
+++ /dev/null
@@ -1,120 +0,0 @@
-<?php
-/**
- * RenewalSelector - read-only selection: given a contract's head-cycle fields, decide
- * which cycle to bill, or nothing. It performs no writes and touches no gateway; it turns
- * a {@see RenewalCandidate} into the cycle number to bill, or null to skip. The caller
- * builds the {@see RenewalIntent} the money-path executes.
- *
- * It encodes two selection policies over the same `process()` primitive (which bills whatever
- * cycle it is handed and owns no due policy):
- *
- * - scheduled ({@see self::select_scheduled_cycle()}): advance to the next cycle once the current
- * period has ended (the due-guard), or retry a still-in-flight head. The guard anchors on the
- * head's `ends_at_gmt` - immutable once settled - so it is race-free: an overlapping run that
- * reads a just-billed head sees its end still in the future and does not charge ahead.
- * - admin-triggered ({@see self::select_manual_cycle()}): force the next cycle regardless of the
- * due-guard, or retry a failed/stalled head - the admin is deciding, not the schedule.
- *
- * WordPress-free by construction: `$now` is passed in.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-use DateTimeImmutable;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * The renewal cycle selector - scheduled and admin-triggered policies.
- */
-final class RenewalSelector {
-
- /**
- * Resolve the cycle a scheduled renewal should bill for this candidate, or null to skip.
- *
- * @param RenewalCandidate $candidate The candidate (contract + head fields).
- * @param DateTimeImmutable $now The scan moment.
- * @return int|null The cycle count to bill, or null when nothing is due.
- */
- public function select_scheduled_cycle( RenewalCandidate $candidate, DateTimeImmutable $now ): ?int {
- $count = $candidate->get_head_count();
- if ( null === $count ) {
- // A countless head is a corrupt chain the scan should not surface; refuse to guess.
- return null;
- }
-
- $status = $candidate->get_head_status();
-
- // Head settled forward: advance to the next cycle, but only once its period has begun.
- // The guard is the charge-ahead defence - a just-billed head whose period runs into the
- // future is not yet due for its successor.
- if ( CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status ) {
- if ( ! self::has_period_ended( $candidate->get_head_ends_at_gmt(), $now ) ) {
- return null;
- }
- return $count + 1;
- }
-
- // Head still in flight: retry the same cycle. The money-path reclaims a stalled one
- // (the scan only surfaces a pending head whose crash-recovery lease has expired).
- if ( CycleStatus::PENDING === $status ) {
- return $count;
- }
-
- // failed (awaits dunning) / processing (awaits its gateway): not selectable here. The
- // scan already excludes them; this is a defensive skip.
- return null;
- }
-
- /**
- * Resolve the cycle a manual (admin-triggered) renewal should bill for this candidate, or
- * null to skip. Unlike the scheduled path this applies no due-guard - the admin is forcing
- * the renewal - so a settled head advances to the next cycle even before its period ends,
- * while a failed or still-pending head is re-attempted at its own count. A `processing` head
- * (awaiting its gateway) or a countless head is not manually renewable.
- *
- * @param RenewalCandidate $candidate The candidate (contract + head fields).
- * @return int|null The cycle count to bill, or null when nothing is renewable.
- */
- public function select_manual_cycle( RenewalCandidate $candidate ): ?int {
- $count = $candidate->get_head_count();
- if ( null === $count ) {
- return null;
- }
-
- $status = $candidate->get_head_status();
-
- // Settled forward: force the next cycle. Its period continues from the previous end, so
- // the schedule is preserved (a prepay), not reset to now.
- if ( CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status ) {
- return $count + 1;
- }
-
- // Failed (retry) or still in flight (reclaim a stalled one): re-attempt the same cycle.
- if ( CycleStatus::FAILED === $status || CycleStatus::PENDING === $status ) {
- return $count;
- }
-
- // processing: awaiting its gateway - a manual trigger cannot preempt an in-flight charge.
- return null;
- }
-
- /**
- * Whether a period ending at `$ends_at_gmt` has ended by `$now` - the scheduled due-guard.
- * An unparsable end is treated as not ended (never charge ahead on bad data). Internal to
- * selection: `process()` bills whatever cycle it is handed and applies no due policy of its own.
- *
- * @param string $ends_at_gmt The head period end (GMT string).
- * @param DateTimeImmutable $now The scan moment.
- */
- private static function has_period_ended( string $ends_at_gmt, DateTimeImmutable $now ): bool {
- $ends_at = strtotime( $ends_at_gmt . ' UTC' );
- return false !== $ends_at && $ends_at <= $now->getTimestamp();
- }
-}
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 91b34e0d915..8068b20bbba 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -6,10 +6,11 @@
* The contract is the live source of truth. A chain is NOT a stored entity: it is
* the pair `(contract_id, kind)`, with its head and counters derived from the cycle
* rows. The entity never carries a cycle graph in memory, so cycles are reached
- * through purpose-built reads ({@see self::find_chain_head()},
- * etc.) and written one at a time ({@see self::append_cycle()}, {@see self::update_cycle()}).
- * There is no whole-graph `save()`. Snapshots are deduped by copy-forward (reuse the
- * previous cycle's snapshot id when plan / items are unchanged), via {@see SnapshotStore}.
+ * through purpose-built reads ({@see self::find_chain_head()}, etc.), appended one at a
+ * time ({@see self::append_cycle()}) and changed only through compare-and-set writes
+ * ({@see self::claim_cycle()}, {@see self::transition_cycle()}).
+ * There is no whole-graph `save()`. Cycle snapshot ids are stored as given and their
+ * value objects attached on read; nothing writes snapshot rows on append.
* Meta is read and written only through the meta methods ({@see self::add_meta()} etc.),
* so a whole-contract write never rewrites meta. It opens no transactions and keeps no
* object cache; a caller may wrap several calls in its own transaction.
@@ -43,23 +44,6 @@ final class ContractRepository {
*/
private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
- /**
- * The per-contract typed snapshot store.
- *
- * @var SnapshotStore
- */
- private $snapshots;
-
- /**
- * Build a repository over the given snapshot store.
- *
- * @param SnapshotStore|null $snapshots Snapshot store; a default instance is
- * created when omitted.
- */
- public function __construct( ?SnapshotStore $snapshots = null ) {
- $this->snapshots = $snapshots ?? new SnapshotStore();
- }
-
/**
* Insert a new contract and its items and addresses.
*
@@ -107,9 +91,9 @@ final class ContractRepository {
* Persist changes to an existing contract and its child rows.
*
* Updates the contract row in place, then reconciles items / addresses only when
- * they differ (meta is never touched) - so the common renewal-cache write (status, next_payment_gmt)
- * does not churn child rows. The write seam a later transaction-handling change
- * wraps; no transaction now (see {@see self::insert()}).
+ * they differ (meta is never touched). A `next_payment_gmt` that differs from the stored
+ * one (including null) releases the due-dispatch claim; re-saving the stored moment keeps
+ * it. No transaction (see {@see self::insert()}).
*
* @param Contract $contract Contract to update. Must have an id whose row still exists.
* @return bool True when the contract row was updated (or already current).
@@ -123,7 +107,7 @@ final class ContractRepository {
$this->assert_contract_exists( $id );
- $this->update_contract_row( $contract );
+ $this->write_contract_row( $id, $contract->to_storage() );
$this->sync_children( $contract );
return true;
@@ -132,8 +116,10 @@ final class ContractRepository {
/**
* Write only the named contract columns (plus `date_updated_gmt`) from the entity,
* and replace items / addresses when named. Unnamed columns keep their stored
- * values, so a concurrent write to them is not reverted. Existence is checked only
- * when the row write changes nothing, before any child write.
+ * values, so a concurrent write to them is not reverted. The named columns land in one
+ * statement, together or not at all. Writing a `next_payment_gmt` that differs from the
+ * stored one (including null) also releases the due-dispatch claim; re-saving the stored
+ * moment keeps it. Existence is checked only when the row write changes nothing.
*
* @param Contract $contract Contract carrying the values. Must have an id.
* @param array<int, string> $fields Contract column names, plus `items` / `addresses`.
@@ -142,8 +128,6 @@ final class ContractRepository {
* @throws \RuntimeException If the contract has no id or the write fails.
*/
public function update_fields( Contract $contract, array $fields ): bool {
- global $wpdb;
-
$id = $contract->get_id();
if ( null === $id ) {
throw new \RuntimeException( 'Cannot update a contract that has no id. Use ContractRepository::insert() for a new contract.' );
@@ -161,16 +145,7 @@ final class ContractRepository {
$columns[ $field ] = $storage[ $field ];
}
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $updated = $wpdb->update(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
- array_merge( $columns, array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) ) ),
- array( 'id' => (int) $id )
- );
-
- if ( false === $updated ) {
- throw new \RuntimeException( 'Failed to update contract.' );
- }
+ $updated = $this->write_contract_row( $id, $columns );
// Zero changed rows: a missing row, or identical values written within the same second.
if ( 0 === $updated && ! $this->exists( $id ) ) {
@@ -188,69 +163,6 @@ final class ContractRepository {
return true;
}
- /**
- * Update the contract row (and children) ONLY while its stored status still matches
- * `$expected_status` - the optimistic compare-and-set for status-sensitive writes,
- * mirroring {@see self::transition_cycle_status()} on the cycle side.
- *
- * The renewal engine's schedule advances read-validate-write the contract row while an
- * extension may change its status (a customer cancel); unconditioned, the slower writer
- * silently clobbers the faster one - a cancel lost to a concurrent settle would
- * resurrect the contract into future billing. Keying the
- * write on the status the caller read makes the race lose LOUDLY: no row matches,
- * false comes back, and the caller reports a conflict or re-reads instead of
- * overwriting.
- *
- * A zero-row result is disambiguated with a follow-up status read (an update whose
- * values happen to be byte-identical also reports zero rows), so false always means
- * "the stored status is no longer `$expected_status`, or the row is gone" - never a
- * false conflict.
- *
- * @param Contract $contract Contract to write. Must have an id.
- * @param string $expected_status The status the caller read; the write lands only while the row still carries it.
- * @return bool True when the row was written (children synced); false when the condition missed.
- * @throws \RuntimeException If the contract has no id, or the write itself errors.
- */
- public function update_if_status( Contract $contract, string $expected_status ): bool {
- global $wpdb;
-
- $id = $contract->get_id();
- if ( null === $id ) {
- throw new \RuntimeException( 'Cannot update a contract that has no id. Use ContractRepository::insert() for a new contract.' );
- }
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $updated = $wpdb->update(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
- array_merge(
- $contract->to_storage(),
- array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
- ),
- array(
- 'id' => (int) $id,
- 'status' => $expected_status,
- )
- );
-
- if ( false === $updated ) {
- throw new \RuntimeException( 'Failed to update contract.' );
- }
-
- if ( 0 === $updated ) {
- $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $current = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$table} WHERE id = %d", $id ) );
- if ( $expected_status !== $current ) {
- return false;
- }
- }
-
- $this->sync_children( $contract );
-
- return true;
- }
-
/**
* Fetch a contract by id, hydrating the live entity with its items / addresses,
* plus its frozen plan terms ({@see Contract::get_plan_snapshot()}) from
@@ -658,39 +570,33 @@ final class ContractRepository {
}
/**
- * Contracts actionable for renewal at `$now`, oldest-due first - the batch dispatcher's scan.
- *
- * 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.
+ * Contracts due at `$now`, never-notified first, then least recently notified - the due dispatcher's scan.
+ *
+ * A contract is due when its `next_payment_gmt` is at or before `$now`, its owner
+ * (`extension_slug`) is one of `$owners`, the engine keeps its schedule (`schedule_source`
+ * is {@see Contract::SCHEDULE_SOURCE_ENGINE}), and it carries no live due-dispatch claim.
+ * A contract whose schedule a gateway or extension keeps is never selected: that party
+ * triggers its own renewals. Status is not read: whether a due contract acts is its
+ * owner's decision. Driven by the `due_owner (extension_slug, next_payment_gmt)` index;
+ * the schedule source and the claim are residual filters.
+ *
+ * `$now` is the due cutoff only; claim liveness is read on the database clock
+ * (`UTC_TIMESTAMP()`), the clock {@see self::claim_contract()} stamps claims on.
+ *
+ * Fairness: a handler is expected to move or clear the due moment it was notified for
+ * (writing a different `next_payment_gmt` releases the claim). A contract whose handler
+ * leaves it in place keeps its expired claim. Rows sort by that claim, oldest first, with
+ * a null claim (never notified for the current due moment) ahead of all of them, then by
+ * due moment: fresh contracts lead, and among repeats the least recently notified goes
+ * next, so contracts stuck on an older due moment cannot starve a later-due retry. The
+ * sort reads the owners' whole due backlog and filesorts it rather than walking
+ * `due_owner` in order; measured cheap at realistic backlogs (about 13 ms warm for
+ * 20,000 due rows) and acceptable before the schema freeze. `LIMIT` bounds the result.
+ *
+ * @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.
+ * @return array<int, array{id: int, extension_slug: string, next_payment_gmt: string}> Due rows, never-notified first, then least recently notified, then oldest-due.
*/
public function find_due( DateTimeImmutable $now, int $limit, array $owners ): array {
if ( $limit < 1 ) {
@@ -705,41 +611,22 @@ final class ContractRepository {
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' );
+ $cutoff = self::format_utc( $now );
$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,
- )
- );
+ $args = array_merge( $owners, array( $cutoff, Contract::SCHEDULE_SOURCE_ENGINE, $limit ) );
// Table names cannot be bound, so they are interpolated; every value is a placeholder.
- // The owner placeholder list is generated, one `%s` per registered owner in `$args`.
+ // The owner placeholder list is generated, one `%s` per 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
- FROM {$contracts} c
- 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.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
+ "SELECT id, extension_slug, next_payment_gmt
+ FROM {$contracts}
+ WHERE extension_slug IN ( {$owner_placeholders} ) AND next_payment_gmt IS NOT NULL AND next_payment_gmt <= %s
+ AND schedule_source = %s
+ AND ( claimed_until IS NULL OR claimed_until <= UTC_TIMESTAMP() )
+ ORDER BY claimed_until ASC, next_payment_gmt ASC, id ASC
LIMIT %d",
$args
),
@@ -747,11 +634,11 @@ final class ContractRepository {
);
// 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
+ // A failed scan otherwise reads exactly like "nothing due" and dispatch stalls
// store-wide with no signal; the return stays empty either way.
if ( '' !== $wpdb->last_error ) {
wc_get_logger()->error(
- sprintf( 'ContractRepository::find_due(): due-index scan failed - renewals may stall until this is fixed. %s', $wpdb->last_error ),
+ sprintf( 'ContractRepository::find_due(): due-index scan failed - due dispatch may stall until this is fixed. %s', $wpdb->last_error ),
array( 'source' => self::LOG_SOURCE )
);
}
@@ -761,18 +648,61 @@ final class ContractRepository {
if ( ! is_array( $row ) ) {
continue;
}
- $head_count = $row['head_count'] ?? null;
- $result[] = new RenewalCandidate(
- Coercion::coerce_int( $row['contract_id'] ?? 0 ),
- null === $head_count ? null : Coercion::coerce_int( $head_count ),
- Coercion::coerce_string( $row['head_status'] ?? '' ),
- Coercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
+ $result[] = array(
+ 'id' => Coercion::coerce_int( $row['id'] ?? 0 ),
+ 'extension_slug' => Coercion::coerce_string( $row['extension_slug'] ?? '' ),
+ 'next_payment_gmt' => Coercion::coerce_string( $row['next_payment_gmt'] ?? '' ),
);
}
return $result;
}
+ /**
+ * Take the due-dispatch claim on a contract for `$ttl_seconds`.
+ *
+ * One conditional write: it lands only while the contract still carries the
+ * `next_payment_gmt` the caller read and no live claim, so of two overlapping
+ * dispatchers exactly one wins. The claim is stamped and its liveness compared on the
+ * database clock (`UTC_TIMESTAMP()`) at the moment of the write, the same clock cycle
+ * claims use, so a claim taken late in a slow batch still lives its full TTL. Writing a
+ * different `next_payment_gmt` (or null) releases the claim; re-saving the same moment
+ * keeps it (see {@see self::update_fields()} and {@see self::update()}).
+ *
+ * @param int $contract_id Contract id.
+ * @param string $seen_next_payment_gmt The `next_payment_gmt` the caller read (UTC, `Y-m-d H:i:s`).
+ * @param int $ttl_seconds Claim lifetime in seconds.
+ * @return bool True when this call took the claim.
+ */
+ public function claim_contract( int $contract_id, string $seen_next_payment_gmt, int $ttl_seconds ): bool {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $updated = $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$table} SET claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )
+ WHERE id = %d AND next_payment_gmt = %s AND ( claimed_until IS NULL OR claimed_until <= UTC_TIMESTAMP() )",
+ $ttl_seconds,
+ $contract_id,
+ $seen_next_payment_gmt
+ )
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ return 1 === $updated;
+ }
+
+ /**
+ * Format a moment as a UTC `Y-m-d H:i:s` column value.
+ *
+ * @param DateTimeImmutable $moment Moment to format.
+ */
+ private static function format_utc( DateTimeImmutable $moment ): string {
+ return $moment->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+ }
+
/**
* Customer-scoped contract list, newest first - the customer portal's list read.
*
@@ -1068,26 +998,28 @@ final class ContractRepository {
}
/**
- * Append a cycle to its chain `(contract_id, kind)`, copy-forwarding snapshots.
+ * Append a cycle to its chain `(contract_id, kind)`.
*
- * Assigns an unassigned `sequence_no` (the chain head's plus one, or 1), resolves the
- * cycle's snapshots (reused from `$previous` when unchanged, else inserted fresh), then
- * inserts the cycle row and stamps the generated id back onto the entity. The chain's
+ * Assigns an unassigned `sequence_no` (the chain head's plus one, or 1), then inserts
+ * the cycle row and stamps the generated id back onto the entity. The chain's
* UNIQUE indexes guard concurrent appends and duplicate counts. The seam a later
* transaction-handling change wraps.
*
- * @param Cycle $cycle Cycle to append. Carries its contract id and kind.
- * @param Cycle|null $previous The chain's previous cycle, when copy-forward of its
- * snapshot ids should be considered; null for the first cycle.
+ * With `$claim_seconds`, the cycle is appended claimed: the same INSERT stamps
+ * `claimed_until` from the database clock, like {@see self::claim_cycle()} and
+ * {@see self::transition_cycle()}, so the claim and the row land together. The entity's
+ * own claim value is never written.
+ *
+ * @param Cycle $cycle Cycle to append. Carries its contract id and kind.
+ * @param int|null $claim_seconds Claim length in seconds from the database's now, or null to append unclaimed.
* @throws DuplicateCycleException If a chain UNIQUE index rejects the row (the
* position is already claimed - the create-as-claim race).
- * @throws \RuntimeException If a snapshot write or the cycle insert fails for any
- * other reason (the database error is in the message).
+ * @throws \RuntimeException If the cycle insert fails for any other reason (the
+ * database error is in the message).
*/
- public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
+ public function append_cycle( Cycle $cycle, ?int $claim_seconds = null ): void {
$this->assign_next_sequence_no( $cycle );
- $this->resolve_cycle_snapshots( $cycle, $previous );
- $this->insert_cycle( $cycle );
+ $this->insert_cycle( $cycle, $claim_seconds );
}
/**
@@ -1105,135 +1037,119 @@ final class ContractRepository {
}
/**
- * Update an existing cycle row.
- *
- * @param Cycle $cycle Cycle to update. Must have an id whose row still exists.
- * @throws \RuntimeException If the cycle has no id, the update fails, or its row
- * no longer exists (stale id or concurrent delete).
+ * Take the claim on a cycle in `$status` whose claim has expired or was never set, for
+ * `$claim_seconds`. A compare-and-set on the status and the claim: among concurrent
+ * callers exactly one matches the row. The expiry check and the new claim use the
+ * database clock (`UTC_TIMESTAMP()`), the one clock every worker shares.
+ *
+ * @param int $cycle_id Cycle id.
+ * @param string $status The status the caller read; the compare-and-set predicate (not validated).
+ * @param int $claim_seconds Claim length in seconds, from the database's now.
+ * @return bool True when this caller took the claim.
*/
- public function update_cycle( Cycle $cycle ): void {
+ public function claim_cycle( int $cycle_id, string $status, int $claim_seconds ): bool {
global $wpdb;
- $id = $cycle->get_id();
- if ( null === $id ) {
- throw new \RuntimeException( 'Cannot update a cycle that has no id. Use ContractRepository::append_cycle() for a new cycle.' );
- }
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $updated = $wpdb->update(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ),
- array_merge(
- $cycle->to_storage(),
- array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
- ),
- array( 'id' => (int) $id )
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$table} SET claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), date_updated_gmt = UTC_TIMESTAMP()
+ WHERE id = %d AND status = %s AND ( claimed_until IS NULL OR claimed_until <= UTC_TIMESTAMP() )",
+ $claim_seconds,
+ $cycle_id,
+ $status
+ )
);
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- // date_updated_gmt always changes, so a matched row reports 1 affected row; 0
- // means no row matched (stale id or concurrent delete) and must fail loudly.
- if ( false === $updated || 0 === $updated ) {
- throw new \RuntimeException( 'Cycle row not found during update (concurrent delete or stale id).' );
- }
+ return false !== $result && 1 === $wpdb->rows_affected;
}
/**
- * Atomically reclaim a stalled `pending` cycle whose crash-recovery lease has expired,
- * extending the lease by `$lease_ttl_seconds`. The compare-and-set that makes reclaim
- * race-safe: the predicate keys on `claimed_until <= now`, so among concurrent workers
- * only the first to run the UPDATE matches a row - it writes the future lease, after
- * which every other worker's `<=` predicate matches zero rows.
- *
- * Both the expiry predicate and the fresh lease anchor on the DATABASE clock
- * (`UTC_TIMESTAMP()`) - the one clock every worker shares - so a worker whose PHP clock
- * runs fast cannot take over a lease that is still live in real terms, and the lease it
- * stamps means the same thing to every other worker.
- *
- * @param int $cycle_id The stalled cycle's id.
- * @param int $lease_ttl_seconds The lease window to stamp, measured from the DB's now.
- * @return bool True when this caller won the reclaim (exactly one row updated); false
- * when another worker already reclaimed it (zero rows matched).
+ * Move a cycle from `$from_status` to `$to_status` as a compare-and-set on its status:
+ * one UPDATE that lands only while the row still carries `$from_status`, so among
+ * concurrent callers exactly one wins. `$changes` keys present are written: `order_id`
+ * and `reason` (null clears); `claim_seconds` sets a claim from the database's now, and
+ * without it the claim is released. A same-status call only writes `$changes`, so it
+ * returns true whenever the row still carries the status, even when nothing changed.
+ *
+ * @param int $cycle_id Cycle id.
+ * @param string $from_status The status the caller read; the compare-and-set predicate (not validated).
+ * @param string $to_status The status to write; must be a registered cycle status.
+ * @param array<string, mixed> $changes Optional `order_id` (?int), `reason` (?string), `claim_seconds` (int).
+ * @return bool True when this caller won the transition (for a same-status call: the row carries the status).
+ * @throws \DomainException If `$to_status` is not a registered cycle status.
*/
- public function reclaim_expired_cycle( int $cycle_id, int $lease_ttl_seconds ): bool {
+ public function transition_cycle( int $cycle_id, string $from_status, string $to_status, array $changes ): bool {
global $wpdb;
- $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ if ( ! CycleStatus::is_registered( $to_status ) ) {
+ throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle(): cycle status "%s" is not registered.', $to_status ) ) );
+ }
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), date_updated_gmt = UTC_TIMESTAMP() WHERE id = %d AND status = %s AND claimed_until IS NOT NULL AND claimed_until <= UTC_TIMESTAMP()", $lease_ttl_seconds, $cycle_id, CycleStatus::PENDING ) );
+ $set = array( 'status = %s' );
+ $values = array( $to_status );
- // Exactly one row matched means this caller won the CAS; 0 means another worker
- // already extended the lease (or the cycle settled) between read and write.
- return false !== $result && 1 === $wpdb->rows_affected;
- }
+ if ( array_key_exists( 'order_id', $changes ) ) {
+ if ( null === $changes['order_id'] ) {
+ $set[] = 'order_id = NULL';
+ } else {
+ $set[] = 'order_id = %d';
+ $values[] = Coercion::coerce_int( $changes['order_id'] );
+ }
+ }
- /**
- * Re-claim a failed cycle for an admin-triggered retry, as an atomic compare-and-set:
- * flip it back to `pending` and stamp a fresh lease only while it is still `failed`.
- * Returns true when this caller won (exactly one row matched), false when it was already
- * re-claimed or has since moved on.
- *
- * The fresh lease anchors on the DATABASE clock (`UTC_TIMESTAMP()`), the shared reference
- * clock for all lease arbitration.
- *
- * @param int $cycle_id The cycle to re-claim.
- * @param int $lease_ttl_seconds The lease window to stamp, measured from the DB's now.
- */
- public function reclaim_failed_cycle( int $cycle_id, int $lease_ttl_seconds ): bool {
- global $wpdb;
+ if ( array_key_exists( 'reason', $changes ) ) {
+ if ( null === $changes['reason'] ) {
+ $set[] = 'reason = NULL';
+ } else {
+ $set[] = 'reason = %s';
+ $values[] = Coercion::coerce_string( $changes['reason'] );
+ }
+ }
- $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ if ( array_key_exists( 'claim_seconds', $changes ) ) {
+ $set[] = 'claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )';
+ $values[] = Coercion::coerce_int( $changes['claim_seconds'] );
+ } else {
+ $set[] = 'claimed_until = NULL';
+ }
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, claimed_until = DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND ), date_updated_gmt = UTC_TIMESTAMP() WHERE id = %d AND status = %s", CycleStatus::PENDING, $lease_ttl_seconds, $cycle_id, CycleStatus::FAILED ) );
+ $set[] = 'date_updated_gmt = %s';
+ $values[] = gmdate( 'Y-m-d H:i:s' );
+ $values[] = $cycle_id;
+ $values[] = $from_status;
- return false !== $result && 1 === $wpdb->rows_affected;
- }
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $set_clause = implode( ', ', $set );
- /**
- * Settle a cycle's outcome as an atomic compare-and-set on its status: move it from
- * `$from_status` to `$to_status`, stamp the settling order and reason, and clear the
- * crash-recovery lease - all in one UPDATE gated on the row still being in `$from_status`.
- * Among concurrent settlers (the post-charge reconciliation and the order-status listener
- * 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; 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;
+ // The SET clause is built from fixed fragments above; every value is a placeholder.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET {$set_clause} WHERE id = %d AND status = %s", $values ) );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
- if ( ! CycleStatus::is_registered( $to_status ) ) {
- throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle_status(): cycle status "%s" is not registered.', $to_status ) ) );
+ if ( false === $result ) {
+ return false;
}
- $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
-
- if ( null === $reason ) {
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, order_id = %d, reason = NULL, claimed_until = NULL, date_updated_gmt = %s WHERE id = %d AND status = %s", $to_status, $order_id, gmdate( 'Y-m-d H:i:s' ), $cycle_id, $from_status ) );
- } else {
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $result = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, order_id = %d, reason = %s, claimed_until = NULL, date_updated_gmt = %s WHERE id = %d AND status = %s", $to_status, $order_id, $reason, gmdate( 'Y-m-d H:i:s' ), $cycle_id, $from_status ) );
+ if ( 1 === $wpdb->rows_affected ) {
+ return true;
}
- return false !== $result && 1 === $wpdb->rows_affected;
+ // MySQL counts changed rows, not matched ones: a same-status call whose values are
+ // already stored matches the row but changes nothing. It still succeeded when the row
+ // carries the status.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return $from_status === $to_status && null !== $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d AND status = %s", $cycle_id, $to_status ) );
}
/**
* The chain's head - its most-recent cycle (highest `sequence_no` in `(contract_id,
* kind)`) - or null when the chain is empty. The head is the chain's growth point, not
- * necessarily the cycle current by date (a forced early renewal bills a head whose
- * period lies ahead). Snapshots are decoded into typed value objects only for an
- * in-flight cycle (see {@see self::hydrate_cycle()}).
+ * necessarily the cycle current by date (an extension may append a head whose
+ * period lies ahead).
*
* @param int $contract_id Contract id.
* @param string $kind Chain kind. Defaults to billing.
@@ -1256,7 +1172,7 @@ final class ContractRepository {
/**
* A window of a chain's cycle history, newest first - the paginated read for
- * history screens. Snapshots are decoded only for any in-flight cycle in the window.
+ * history screens.
*
* @param int $contract_id Contract id.
* @param string $kind Chain kind. Defaults to billing.
@@ -1285,10 +1201,10 @@ final class ContractRepository {
/**
* All cycles linked to `$order_id`, across kinds and contracts. `order_id` is a
* non-1:1 reference (an aggregate order may serve many cycles), so this returns
- * every match. Snapshots are decoded for any in-flight cycle.
+ * every match.
*
* @param int $order_id WooCommerce order id.
- * @return array<int, Cycle> Cycles linked to the order, oldest first.
+ * @return array<int, Cycle> Cycles linked to the order, newest first.
*/
public function find_cycles_by_order_id( int $order_id ): array {
global $wpdb;
@@ -1296,7 +1212,7 @@ final class ContractRepository {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY id DESC", $order_id ), ARRAY_A );
$cycles = array();
foreach ( is_array( $rows ) ? $rows : array() as $row ) {
@@ -1309,81 +1225,8 @@ final class ContractRepository {
}
/**
- * Resolve a cycle's in-memory snapshot value objects into stored row ids (write-once).
- *
- * Per snapshot type: when the cycle carries a fresh value object and no stored id
- * yet, the id is copy-forwarded from `$previous` (when payload + schema version
- * match) or freshly inserted, then stamped onto the cycle. A cycle that already
- * carries an id is left as is - a frozen reference is never re-pointed.
- *
- * @param Cycle $cycle Cycle whose snapshots to resolve.
- * @param Cycle|null $previous The previous cycle to copy-forward from, or null.
- */
- private function resolve_cycle_snapshots( Cycle $cycle, ?Cycle $previous ): void {
- $contract_id = $cycle->get_contract_id();
-
- $plan = $cycle->get_plan_snapshot();
- if ( $plan instanceof PlanSnapshot && null === $cycle->get_plan_snapshot_id() ) {
- $cycle->set_plan_snapshot_id(
- $this->copy_forward_or_insert(
- $contract_id,
- SnapshotStore::TYPE_PLAN,
- $plan->get_selling_plan_id(),
- $plan->to_payload(),
- $plan->get_schema_version(),
- null === $previous ? null : $previous->get_plan_snapshot_id()
- )
- );
- }
-
- $items = $cycle->get_items_snapshot();
- if ( $items instanceof ItemsSnapshot && null === $cycle->get_items_snapshot_id() ) {
- $cycle->set_items_snapshot_id(
- $this->copy_forward_or_insert(
- $contract_id,
- SnapshotStore::TYPE_ITEMS,
- null,
- $items->to_payload(),
- $items->get_schema_version(),
- null === $previous ? null : $previous->get_items_snapshot_id()
- )
- );
- }
- }
-
- /**
- * Reuse the previous cycle's snapshot id when its payload + schema version match,
- * else insert a fresh row.
- *
- * The match is a strict comparison against the JSON-decoded stored payload, so
- * payload values must be JSON-round-trip-safe scalars: ints and strings (money as
- * decimal strings, never PHP floats - a float may not decode back identical,
- * defeating the reuse).
- *
- * @param int $contract_id Owning contract id.
- * @param string $snapshot_type Snapshot type (`plan` | `items`).
- * @param int|null $parent_id Weak link to the source, or null.
- * @param array<int|string, mixed> $payload The new snapshot payload.
- * @param int $schema_version Payload-format version.
- * @param int|null $previous_snapshot_id The previous cycle's snapshot id, or null.
- * @return int The reused or newly-inserted snapshot id.
- */
- private function copy_forward_or_insert( int $contract_id, string $snapshot_type, ?int $parent_id, array $payload, int $schema_version, ?int $previous_snapshot_id ): int {
- if ( null !== $previous_snapshot_id ) {
- $previous = $this->find_snapshot_payload( $previous_snapshot_id );
- if ( null !== $previous && $previous['schema_version'] === $schema_version && $previous['payload'] === $payload ) {
- return $previous_snapshot_id;
- }
- }
-
- return $this->snapshots->insert( $contract_id, $snapshot_type, $parent_id, $payload, $schema_version );
- }
-
- /**
- * 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. {@see CycleStatus::is_terminal()} is false for an
- * unknown or extension-registered status, so such a cycle's snapshots are decoded.
+ * Hydrate a cycle row, attaching typed snapshot value objects for the snapshot ids
+ * it carries (a cycle without snapshot ids costs no extra read).
*
* @param array<string, mixed> $row Cycle row.
* @return Cycle The hydrated cycle.
@@ -1391,46 +1234,75 @@ final class ContractRepository {
private function hydrate_cycle( array $row ): Cycle {
$cycle = Cycle::from_storage( $row );
- if ( CycleStatus::is_terminal( $cycle->get_status()->get_value() ) ) {
- return $cycle;
- }
-
- $plan = $this->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
- if ( $plan instanceof PlanSnapshot ) {
- $cycle->set_plan_snapshot( $plan );
+ $plan_snapshot_id = $cycle->get_plan_snapshot_id();
+ if ( null !== $plan_snapshot_id ) {
+ $plan = $this->find_plan_snapshot( $plan_snapshot_id );
+ if ( $plan instanceof PlanSnapshot ) {
+ $cycle->set_plan_snapshot( $plan );
+ }
}
- $items = $this->find_items_snapshot( $cycle->get_items_snapshot_id() );
- if ( $items instanceof ItemsSnapshot ) {
- $cycle->set_items_snapshot( $items );
+ $items_snapshot_id = $cycle->get_items_snapshot_id();
+ if ( null !== $items_snapshot_id ) {
+ $items = $this->find_items_snapshot( $items_snapshot_id );
+ if ( $items instanceof ItemsSnapshot ) {
+ $cycle->set_items_snapshot( $items );
+ }
}
return $cycle;
}
/**
- * Insert a cycle row and stamp the generated id back onto the entity.
+ * Insert a cycle row and stamp the generated id back onto the entity. Hand-built so
+ * a claim can be stamped from `UTC_TIMESTAMP()` in the same statement; string values
+ * are checked against their column lengths first, as `$wpdb->insert()` would.
*
- * @param Cycle $cycle Cycle to insert. Carries its contract id and kind.
+ * @param Cycle $cycle Cycle to insert. Carries its contract id and kind.
+ * @param int|null $claim_seconds Claim length in seconds from the database's now, or null for none.
* @throws DuplicateCycleException If a chain UNIQUE index rejects the row.
- * @throws \RuntimeException If the insert fails for any other reason.
+ * @throws \RuntimeException If a value is longer than its column, or the insert fails for any other reason.
*/
- private function insert_cycle( Cycle $cycle ): void {
+ private function insert_cycle( Cycle $cycle, ?int $claim_seconds ): void {
global $wpdb;
- $now = gmdate( 'Y-m-d H:i:s' );
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ $columns = $cycle->to_storage();
+ unset( $columns['claimed_until'] );
+ $columns['date_created_gmt'] = $now;
+ $columns['date_updated_gmt'] = $now;
+
+ $names = array();
+ $placeholders = array();
+ $values = array();
+ foreach ( $columns as $column => $value ) {
+ $names[] = "`{$column}`";
+ if ( null === $value ) {
+ $placeholders[] = 'NULL';
+ continue;
+ }
+ if ( is_string( $value ) ) {
+ self::assert_fits_column( $table, $column, $value );
+ }
+ $placeholders[] = '%s';
+ $values[] = (string) $value;
+ }
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $inserted = $wpdb->insert(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ),
- array_merge(
- $cycle->to_storage(),
- array(
- 'date_created_gmt' => $now,
- 'date_updated_gmt' => $now,
- )
- )
- );
+ if ( null !== $claim_seconds ) {
+ $names[] = '`claimed_until`';
+ $placeholders[] = 'DATE_ADD( UTC_TIMESTAMP(), INTERVAL %d SECOND )';
+ $values[] = $claim_seconds;
+ }
+
+ $name_list = implode( ', ', $names );
+ $placeholder_list = implode( ', ', $placeholders );
+
+ // Column names come from the entity's storage keys, never from input; every value is a placeholder.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ $inserted = $wpdb->query( $wpdb->prepare( "INSERT INTO `{$table}` ( {$name_list} ) VALUES ( {$placeholder_list} )", $values ) );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( false === $inserted ) {
$db_error = $wpdb->last_error;
@@ -1447,13 +1319,10 @@ final class ContractRepository {
/**
* Decode a stored plan snapshot row into a typed value object.
*
- * Public so the renewal money-path can resolve a contract's own frozen plan terms
- * (the cadence to bill the next cycle under) from its `plan_snapshot_id`.
- *
* @param int|null $snapshot_id Snapshot row id, or null.
* @return PlanSnapshot|null The decoded value object, or null.
*/
- public function find_plan_snapshot( ?int $snapshot_id ): ?PlanSnapshot {
+ private function find_plan_snapshot( ?int $snapshot_id ): ?PlanSnapshot {
$decoded = $this->find_snapshot_payload( $snapshot_id );
if ( null === $decoded ) {
return null;
@@ -1573,34 +1442,109 @@ final class ContractRepository {
}
/**
- * Update the contract row in place (no child rows).
- *
- * @param Contract $contract Contract whose row to update. Must have an id.
- * @throws \RuntimeException If the update fails.
+ * Write contract columns (plus `date_updated_gmt`) in one UPDATE statement, so the
+ * row never lands half-written: a status and a due moment written together change
+ * together or not at all.
+ *
+ * When `next_payment_gmt` is among the columns, the same statement releases the
+ * due-dispatch claim only if the due moment really changes: a claim belongs to the
+ * moment it was taken on, so moving or clearing that moment frees the contract for the
+ * next scan, while re-saving it keeps the claim and a handler cannot jump the dispatch
+ * queue by writing the same moment back. MySQL evaluates SET assignments left to
+ * right, so the claim is assigned first, while `next_payment_gmt` still holds the
+ * stored value; `<=>` keeps the comparison NULL-safe.
+ *
+ * A hand-built statement skips the per-column length check `$wpdb->update()` makes, and
+ * WordPress runs without strict SQL modes, so MySQL would silently shorten an over-long
+ * string. Each string is checked against its column length first and refused like
+ * {@see self::insert()} refuses it.
+ *
+ * @param int $id Contract id.
+ * @param array<string, int|string|null> $columns Contract column values keyed by column name (from {@see Contract::to_storage()}).
+ * @return int The number of rows the statement changed.
+ * @throws \RuntimeException If a value is longer than its column, or the write fails.
*/
- private function update_contract_row( Contract $contract ): void {
+ private function write_contract_row( int $id, array $columns ): int {
global $wpdb;
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $updated = $wpdb->update(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
- array_merge(
- $contract->to_storage(),
- array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
- ),
- array( 'id' => (int) $contract->get_id() )
- );
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ $columns['date_updated_gmt'] = gmdate( 'Y-m-d H:i:s' );
+
+ foreach ( $columns as $column => $value ) {
+ if ( is_string( $value ) ) {
+ self::assert_fits_column( $table, $column, $value );
+ }
+ }
+
+ $assignments = array();
+ $values = array();
+
+ if ( array_key_exists( 'next_payment_gmt', $columns ) ) {
+ $next_payment = $columns['next_payment_gmt'];
+ if ( null === $next_payment ) {
+ $assignments[] = '`claimed_until` = IF( `next_payment_gmt` <=> NULL, `claimed_until`, NULL )';
+ } else {
+ $assignments[] = '`claimed_until` = IF( `next_payment_gmt` <=> %s, `claimed_until`, NULL )';
+ $values[] = (string) $next_payment;
+ }
+ }
+
+ foreach ( $columns as $column => $value ) {
+ if ( null === $value ) {
+ $assignments[] = "`{$column}` = NULL";
+ continue;
+ }
+ $assignments[] = "`{$column}` = %s";
+ $values[] = (string) $value;
+ }
+
+ $values[] = $id;
+ $set = implode( ', ', $assignments );
+
+ // Column names come from the entity's storage keys, never from input; every value is a placeholder.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ $updated = $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET {$set} WHERE `id` = %d", $values ) );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( false === $updated ) {
throw new \RuntimeException( 'Failed to update contract.' );
}
+
+ return (int) $updated;
+ }
+
+ /**
+ * Refuse a string longer than its column, measured the way `$wpdb` measures it
+ * (characters for CHAR/VARCHAR, bytes for TEXT/BLOB types). A column whose length
+ * `$wpdb` cannot read (non-MySQL, or a non-string type such as DATETIME) is not checked.
+ *
+ * @param string $table Prefixed table name.
+ * @param string $column Column name.
+ * @param string $value Value about to be written.
+ * @throws \RuntimeException If the value is longer than the column.
+ */
+ private static function assert_fits_column( string $table, string $column, string $value ): void {
+ global $wpdb;
+
+ $length = $wpdb->get_col_length( $table, $column );
+ if ( ! is_array( $length ) ) {
+ return;
+ }
+
+ $max = Coercion::coerce_int( $length['length'] ?? 0 );
+ $size = 'byte' === ( $length['type'] ?? '' ) ? strlen( $value ) : mb_strlen( $value, 'UTF-8' );
+ if ( $max > 0 && $size > $max ) {
+ throw new \RuntimeException(
+ esc_html( sprintf( 'Failed to write %s: the value for "%s" is longer than the column allows (%d).', $table, $column, $max ) )
+ );
+ }
}
/**
* Guard against a stale id / concurrent delete before touching any table.
*
- * `$wpdb->update()` returns 0 for both "matched, nothing changed" and "no row
- * matched", so on its own it cannot tell the parent is gone - and a child reconcile
+ * The contract row UPDATE reports 0 changed rows for both "matched, nothing changed" and
+ * "no row matched", so on its own it cannot tell the parent is gone - and a child reconcile
* would then write orphan rows (no FK enforces the relation). This narrows but does
* not fully close the race; the complete fix (a transaction with SELECT ... FOR UPDATE)
* is the seam a later transaction-handling change wraps.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php
deleted file mode 100644
index 1fad9fb5604..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php
+++ /dev/null
@@ -1,117 +0,0 @@
-<?php
-/**
- * RenewalCandidate - a contract as a candidate for renewal: its id plus the head-cycle
- * fields the renewal selector reads to decide what (if anything) to bill. Produced by
- * {@see ContractRepository::find_due()} - which joins each due contract to its head cycle
- * so the scan can filter to actionable contracts (head billed and due, or head pending
- * with an expired lease) in SQL, keeping non-actionable rows out of the batch budget -
- * and by {@see self::from_cycle()} on the single-contract paths (scheduled or manual),
- * where the caller has already loaded the head.
- *
- * A lean read-model, not the full {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle}:
- * it carries only the head fields selection reads, so the scan does not hydrate snapshots
- * for a decision the money-path re-reads anyway.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
-
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * A renewal candidate: a contract plus its head-cycle fields.
- */
-final class RenewalCandidate {
-
- /**
- * Contract id.
- *
- * @var int
- */
- private $contract_id;
-
- /**
- * Head cycle chargeable count, or null for a countless (corrupt) head.
- *
- * @var int|null
- */
- private $head_count;
-
- /**
- * Head cycle status string (a {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus} value).
- *
- * @var string
- */
- private $head_status;
-
- /**
- * Head cycle period end (GMT string) - the moment the next cycle becomes due.
- *
- * @var string
- */
- private $head_ends_at_gmt;
-
- /**
- * Build a candidate from a contract and its head-cycle fields.
- *
- * @param int $contract_id Contract id.
- * @param int|null $head_count Head chargeable count (null when the head has none).
- * @param string $head_status Head status string.
- * @param string $head_ends_at_gmt Head period end (GMT string).
- */
- public function __construct( int $contract_id, ?int $head_count, string $head_status, string $head_ends_at_gmt ) {
- $this->contract_id = $contract_id;
- $this->head_count = $head_count;
- $this->head_status = $head_status;
- $this->head_ends_at_gmt = $head_ends_at_gmt;
- }
-
- /**
- * Build from a hydrated cycle - the single-contract path, where the caller has already loaded
- * the contract's head cycle (rather than joining it in the scan) and maps its fields in. The
- * head semantics are the caller's to uphold; this only reads the cycle it is handed.
- *
- * @param Cycle $cycle The cycle to read (the contract's head, in the single-contract path).
- */
- public static function from_cycle( Cycle $cycle ): self {
- return new self(
- $cycle->get_contract_id(),
- $cycle->get_count(),
- $cycle->get_status()->get_value(),
- $cycle->get_ends_at_gmt()
- );
- }
-
- /**
- * Contract id.
- */
- public function get_contract_id(): int {
- return $this->contract_id;
- }
-
- /**
- * Head chargeable count, or null.
- */
- public function get_head_count(): ?int {
- return $this->head_count;
- }
-
- /**
- * Head status string.
- */
- public function get_head_status(): string {
- return $this->head_status;
- }
-
- /**
- * Head period end (GMT string).
- */
- public function get_head_ends_at_gmt(): string {
- return $this->head_ends_at_gmt;
- }
-}
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 ad3450d39c1..7b0f5d15909 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -34,9 +34,9 @@ final class SchemaInstaller {
* `(contract_id, kind)`; per-contract snapshots deduped by copy-forward.
* 2.1.0 - rename `app_id` to `extension_slug` in plan_groups table.
* 2.1.1 - add `status` and `sort_order` columns to plans table.
- * 2.2.0 - dispatcher columns: cycle `claimed_until` (crash-recovery lease) and
+ * 2.2.0 - dispatcher columns: cycle `claimed_until` (crash-recovery claim) and
* reserved `retry_at`; a `due_contract (status, next_payment_gmt)` index on
- * contracts for the batch renewal scan.
+ * contracts for the batch due 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
@@ -50,13 +50,21 @@ final class SchemaInstaller {
* `inventory_policy` and their indexes; `billing_policy` nullable; an
* `extension_status (extension_slug, status)` index; new selling_plan_meta
* table (HPOS-style indexes); pre-freeze, recreate the tables.
+ * 2.7.0 - due dispatch: contracts gain a nullable `claimed_until` (the
+ * due-dispatch claim); contracts `schedule_source` is VARCHAR(64) (it holds
+ * extension and gateway slugs) and defaults to 'subscriptions_engine' (was
+ * 'primitive'); cycles drop the `due (kind, status, starts_at_gmt)` index (the
+ * due dispatcher scans contracts, and no cycle read filters on those columns).
+ * dbDelta adds the column and widens `schedule_source` in place, but does not
+ * drop the index or rename stored 'primitive' rows (which the due scan skips):
+ * pre-freeze, recreate the tables.
*
* Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
* does not change an existing column's nullability or drop unused ones, so a dev box
* on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
* to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
*/
- private const VERSION = '2.6.0';
+ private const VERSION = '2.7.0';
/**
* Option key tracking the installed schema version.
@@ -135,7 +143,7 @@ final class SchemaInstaller {
public static function uninstall(): void {
global $wpdb;
- // TODO: Determine what we should do with the tables when uninstalling - WOOSUBS-1718.
+ // TODO: Decide what happens to the tables on uninstall (keep or drop the data).
delete_option( self::VERSION_OPTION );
}
@@ -246,8 +254,9 @@ final class SchemaInstaller {
// `selling_plan_id` and `start_gmt` are NULLABLE until the extension supplies them. There is no generic
// `cycle_count` - counters are per-chain, derived as `MAX(count)` over
// `(contract_id, kind)`. `currency` is first-class (forward-compat for multi-currency
- // recurring; today the store base currency). `schedule_source` distinguishes
- // engine-owned renewals from gateway-owned schedules.
+ // recurring; today the store base currency). `schedule_source` records
+ // who keeps the payment schedule: the engine ('subscriptions_engine', the only
+ // source the due scan dispatches) or a gateway or extension that keeps its own.
$contracts_sql = "CREATE TABLE {$contracts} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
status VARCHAR(20) NOT NULL,
@@ -261,6 +270,7 @@ final class SchemaInstaller {
payment_token_id BIGINT UNSIGNED NULL,
start_gmt DATETIME NULL,
next_payment_gmt DATETIME NULL,
+ claimed_until DATETIME NULL,
plan_snapshot_id BIGINT UNSIGNED NULL,
items_snapshot_id BIGINT UNSIGNED NULL,
billing_total DECIMAL(26,8) NOT NULL DEFAULT 0,
@@ -271,7 +281,7 @@ final class SchemaInstaller {
last_attempt_gmt DATETIME NULL,
trial_end_gmt DATETIME NULL,
end_gmt DATETIME NULL,
- schedule_source VARCHAR(20) NOT NULL DEFAULT 'primitive',
+ schedule_source VARCHAR(64) NOT NULL DEFAULT 'subscriptions_engine',
date_created_gmt DATETIME NOT NULL,
date_updated_gmt DATETIME NOT NULL,
PRIMARY KEY (id),
@@ -330,12 +340,11 @@ final class SchemaInstaller {
// chain from holding two cycles at one position; `chain_count` (UNIQUE) is the
// per-charge idempotency anchor, with `count` nullable so non-counting cycles
// (e.g. future trial periods) coexist freely under MySQL's NULL-distinct rule.
- // The `due` index keys the dispatcher's due-scan in (kind, status, starts_at_gmt)
- // order, since billing-in-advance fires at `starts_at_gmt`. `order_id` is non-1:1
- // (an aggregate order may serve many cycles); `contract_kind` serves targeted
- // per-chain reads (MAX(count), head). `claimed_until` is the crash-recovery lease:
- // it is stamped when a `pending` cycle is claimed and a stuck pending cycle past it
- // is reclaimable; the create-as-claim UNIQUE remains the primary concurrency guard.
+ // `order_id` is non-1:1 (an aggregate order may serve many cycles); `contract_kind`
+ // serves targeted per-chain reads (MAX(count), head). `claimed_until` is the cycle
+ // claim: stamped when a caller appends, claims or transitions a cycle with a claim,
+ // claimable again once it passes; the chain's UNIQUE positions remain the primary
+ // guard against a duplicate cycle.
// `retry_at` is reserved (additive) for a later retry/dunning pass - not wired yet.
$cycles_sql = "CREATE TABLE {$cycles} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -360,7 +369,6 @@ final class SchemaInstaller {
PRIMARY KEY (id),
UNIQUE KEY chain_seq (contract_id, kind, sequence_no),
UNIQUE KEY chain_count (contract_id, kind, count),
- KEY due (kind, status, starts_at_gmt),
KEY order_id (order_id),
KEY contract_kind (contract_id, kind)
) {$collate};";
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
index 22b17374ad5..615d0adfd0e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
@@ -132,6 +132,22 @@ final class ArgumentValidator {
return $value;
}
+ /**
+ * Validate and return a positive integer (a digit string is cast).
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not a positive integer.
+ */
+ public static function validate_positive_int( string $key, $value ): int {
+ $value = self::cast_digit_string( $value );
+ if ( ! is_int( $value ) || $value <= 0 ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a positive integer.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
/**
* Validate and return a non-negative integer (a digit string is cast).
*
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
index 9f794ac9712..b8e80a3098f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -98,7 +98,7 @@ class ContractsTest extends EngineIntegrationTestCase {
'last_attempt_gmt' => '2026-01-03 00:00:00',
'trial_end_gmt' => '2026-01-04 00:00:00',
'end_gmt' => '2027-01-01 00:00:00',
- 'schedule_source' => Contract::SCHEDULE_SOURCE_GATEWAY,
+ 'schedule_source' => 'my_gateway',
'billing_total' => 20,
'discount_total' => '1.5',
'shipping_total' => 5.25,
@@ -138,7 +138,7 @@ class ContractsTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-01-03 00:00:00', $view->get_last_attempt_gmt() );
$this->assertSame( '2026-01-04 00:00:00', $view->get_trial_end_gmt() );
$this->assertSame( '2027-01-01 00:00:00', $view->get_end_gmt() );
- $this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $view->get_schedule_source() );
+ $this->assertSame( 'my_gateway', $view->get_schedule_source() );
$this->assertSame( '20.00000000', $view->get_billing_total() );
$this->assertSame( '1.50000000', $view->get_discount_total() );
$this->assertSame( '5.25000000', $view->get_shipping_total() );
@@ -345,7 +345,7 @@ class ContractsTest extends EngineIntegrationTestCase {
),
),
'money without currency' => array( array( 'billing_total' => '10' ) ),
- 'bad schedule source' => array( array( 'schedule_source' => 'cron' ) ),
+ 'bad schedule source' => array( array( 'schedule_source' => 'Not a slug' ) ),
'non-string method' => array( array( 'payment_method' => 5 ) ),
'items not a list' => array( array( 'items' => array( 'a' => array() ) ) ),
'unknown address type' => array( array( 'addresses' => array( 'home' => array() ) ) ),
@@ -609,6 +609,20 @@ class ContractsTest extends EngineIntegrationTestCase {
}
}
+ /**
+ * The number of stored cycles of a contract, on any chain.
+ *
+ * @param int $contract_id Contract id.
+ */
+ private function stored_cycle_count( int $contract_id ): int {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE contract_id = %d", $contract_id ) );
+ }
+
/**
* Run a contract write while every insert into a child table fails, asserting it throws.
*
@@ -833,7 +847,7 @@ class ContractsTest extends EngineIntegrationTestCase {
public function test_the_first_cycle_takes_the_chain_defaults(): void {
$id = $this->contract_with_currency();
- $cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) )->get_id();
+ $cycle_id = $this->add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) )->get_id();
$cycle = $this->cycle( $id, $cycle_id );
$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
@@ -849,9 +863,9 @@ class ContractsTest extends EngineIntegrationTestCase {
public function test_the_next_cycle_defaults_to_the_next_position(): void {
$id = $this->contract_with_currency();
- Contracts::add_cycle( $id, $this->cycle_args() );
+ $this->add_cycle( $id, $this->cycle_args() );
- $cycle_id = Contracts::add_cycle(
+ $cycle_id = $this->add_cycle(
$id,
$this->cycle_args(
array(
@@ -872,42 +886,53 @@ class ContractsTest extends EngineIntegrationTestCase {
public function test_a_non_counting_cycle_takes_a_null_count(): void {
$id = $this->contract_with_currency();
- $cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => null ) ) )->get_id();
+ $cycle_id = $this->add_cycle( $id, $this->cycle_args( array( 'count' => null ) ) )->get_id();
$this->assertNull( $this->cycle( $id, $cycle_id )->get_count() );
}
- public function test_a_taken_position_is_refused(): void {
+ public function test_a_taken_position_returns_null(): void {
$id = $this->contract_with_currency();
- Contracts::add_cycle( $id, $this->cycle_args() );
+ $this->add_cycle( $id, $this->cycle_args() );
+
+ $this->assertNull( Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) ), 'A lost append returns null.' );
+ $this->assertCount( 1, Contracts::get_cycles( $id ), 'Nothing is written.' );
+ }
- $this->expectException( DomainException::class );
+ public function test_an_over_long_kind_is_refused_and_nothing_is_written(): void {
+ $id = $this->contract_with_currency();
+ $kind = str_repeat( 'k', 21 );
- Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) );
+ try {
+ Contracts::add_cycle( $id, $this->cycle_args( array( 'kind' => $kind ) ) );
+ $this->fail( 'Expected add_cycle() to refuse a kind longer than its column.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
+ }
+ $this->assertSame( 0, $this->stored_cycle_count( $id ), 'Nothing is written.' );
}
public function test_a_null_sequence_no_takes_the_next_position(): void {
$id = $this->contract_with_currency();
- Contracts::add_cycle( $id, $this->cycle_args() );
+ $this->add_cycle( $id, $this->cycle_args() );
- $view = Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => null ) ) );
+ $view = $this->add_cycle( $id, $this->cycle_args( array( 'sequence_no' => null ) ) );
$this->assertSame( 2, $view->get_sequence_no() );
}
- public function test_a_taken_count_is_refused(): void {
+ public function test_a_taken_count_returns_null(): void {
$id = $this->contract_with_currency();
- Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
-
- $this->expectException( DomainException::class );
+ $this->add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
- Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
+ $this->assertNull( Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) ), 'A lost append returns null.' );
+ $this->assertCount( 1, Contracts::get_cycles( $id ), 'Nothing is written.' );
}
public function test_an_explicit_position_is_kept(): void {
$id = $this->contract_with_currency();
- $cycle_id = Contracts::add_cycle(
+ $cycle_id = $this->add_cycle(
$id,
$this->cycle_args(
array(
@@ -924,10 +949,10 @@ class ContractsTest extends EngineIntegrationTestCase {
public function test_another_kind_starts_its_own_chain(): void {
$id = $this->contract_with_currency();
- Contracts::add_cycle( $id, $this->cycle_args() );
- Contracts::add_cycle( $id, $this->cycle_args() );
+ $this->add_cycle( $id, $this->cycle_args() );
+ $this->add_cycle( $id, $this->cycle_args() );
- $view = Contracts::add_cycle( $id, $this->cycle_args( array( 'kind' => 'shipping' ) ) );
+ $view = $this->add_cycle( $id, $this->cycle_args( array( 'kind' => 'shipping' ) ) );
$this->assertSame( 'shipping', $view->get_kind() );
$this->assertSame( 1, $view->get_sequence_no() );
@@ -942,7 +967,7 @@ class ContractsTest extends EngineIntegrationTestCase {
$id = $this->contract_with_currency();
try {
- Contracts::add_cycle( $id, $args );
+ $this->add_cycle( $id, $args );
$this->fail( 'Expected an InvalidArgumentException.' );
} catch ( InvalidArgumentException $e ) {
$this->assertSame( array(), Contracts::get_cycles( $id ), 'No cycle is written.' );
@@ -1003,7 +1028,7 @@ class ContractsTest extends EngineIntegrationTestCase {
public function test_add_cycle_defaults_the_status_to_pending(): void {
$id = $this->contract_with_currency();
- $cycle = Contracts::add_cycle(
+ $cycle = $this->add_cycle(
$id,
array(
'starts_at_gmt' => '2026-01-01 00:00:00',
@@ -1019,7 +1044,7 @@ class ContractsTest extends EngineIntegrationTestCase {
$id = $this->contract_with_currency();
$this->setExpectedIncorrectUsage( Contracts::class . '::add_cycle' );
- $cycle_id = Contracts::add_cycle(
+ $cycle_id = $this->add_cycle(
$id,
$this->cycle_args(
array(
@@ -1037,20 +1062,20 @@ class ContractsTest extends EngineIntegrationTestCase {
$this->expectException( InvalidArgumentException::class );
- Contracts::add_cycle( $id, $this->cycle_args( array( 'currency' => null ) ) );
+ $this->add_cycle( $id, $this->cycle_args( array( 'currency' => null ) ) );
}
public function test_the_cycle_currency_is_the_given_one(): void {
$id = $this->contract_with_currency();
- $cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'currency' => 'EUR' ) ) )->get_id();
+ $cycle_id = $this->add_cycle( $id, $this->cycle_args( array( 'currency' => 'EUR' ) ) )->get_id();
$this->assertSame( 'EUR', $this->cycle( $id, $cycle_id )->get_currency() );
}
public function test_get_cycles_returns_the_appended_cycle_as_a_view(): void {
$id = $this->contract_with_currency();
- $created = Contracts::add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) );
+ $created = $this->add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) );
$history = Contracts::get_cycles( $id );
@@ -1064,6 +1089,292 @@ class ContractsTest extends EngineIntegrationTestCase {
$this->assertSame( 77, $history[0]->get_order_id() );
}
+ /**
+ * @testdox get_cycles refuses a negative limit or offset.
+ *
+ * @dataProvider provide_negative_cycle_windows
+ *
+ * @param int $limit Window size.
+ * @param int $offset Cycles to skip.
+ */
+ public function test_get_cycles_refuses_a_negative_window( int $limit, int $offset ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Contracts::get_cycles( $this->contract_with_currency(), $limit, $offset );
+ }
+
+ /**
+ * @return array<string, array{0: int, 1: int}>
+ */
+ public function provide_negative_cycle_windows(): array {
+ return array(
+ 'negative limit' => array( -1, 0 ),
+ 'negative offset' => array( 20, -1 ),
+ );
+ }
+
+ /**
+ * @testdox A claimed append holds a claim: claim_cycle fails until the claim expires.
+ */
+ public function test_a_claimed_append_holds_a_claim(): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle(
+ $id,
+ $this->cycle_args(
+ array(
+ 'status' => CycleStatus::PENDING,
+ 'claim_seconds' => 900,
+ )
+ )
+ );
+
+ $claimed_until = $this->cycle( $id, $cycle->get_id() )->get_claimed_until_gmt();
+ $this->assertNotNull( $claimed_until );
+ $this->assertGreaterThan( time() + 800, strtotime( $claimed_until . ' UTC' ) );
+ $this->assertFalse( Contracts::claim_cycle( $cycle->get_id(), CycleStatus::PENDING, 900 ), 'The appender holds the cycle.' );
+
+ $this->expire_claim( $cycle->get_id() );
+ $this->assertTrue( Contracts::claim_cycle( $cycle->get_id(), CycleStatus::PENDING, 900 ), 'An expired claim can be taken.' );
+ $this->assertFalse( Contracts::claim_cycle( $cycle->get_id(), CycleStatus::PENDING, 900 ), 'Only once.' );
+ }
+
+ /**
+ * @testdox A claimed append stamps its claim on the database clock, like claim_cycle.
+ */
+ public function test_a_claimed_append_stamps_the_claim_on_the_database_clock(): void {
+ $id = $this->contract_with_currency();
+
+ // Pin the session's database clock far from PHP's; UTC_TIMESTAMP() follows it.
+ $this->pin_db_clock( '2035-01-01 00:00:00' );
+ $cycle = $this->add_cycle(
+ $id,
+ $this->cycle_args(
+ array(
+ 'status' => CycleStatus::PENDING,
+ 'claim_seconds' => 900,
+ )
+ )
+ );
+
+ $this->assertSame( '2035-01-01 00:15:00', $this->cycle( $id, $cycle->get_id() )->get_claimed_until_gmt() );
+ }
+
+ /**
+ * @testdox An invalid claim_seconds is rejected without a write.
+ */
+ public function test_an_invalid_claim_is_rejected(): void {
+ $id = $this->contract_with_currency();
+
+ try {
+ $this->add_cycle( $id, $this->cycle_args( array( 'claim_seconds' => 0 ) ) );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertSame( array(), Contracts::get_cycles( $id ) );
+ }
+
+ $this->expectException( InvalidArgumentException::class );
+ Contracts::claim_cycle( 1, CycleStatus::PENDING, 0 );
+ }
+
+ /**
+ * @testdox claim_cycle wins once on an unclaimed cycle in the status the caller read.
+ */
+ public function test_claim_cycle_on_unclaimed_pending_and_billed_cycles(): void {
+ $id = $this->contract_with_currency();
+ $billed = $this->add_cycle( $id, $this->cycle_args() );
+ $pending = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+ $this->assertTrue( Contracts::claim_cycle( $pending->get_id(), CycleStatus::PENDING, 900 ) );
+ $this->assertFalse( Contracts::claim_cycle( $pending->get_id(), CycleStatus::PENDING, 900 ) );
+ $this->assertFalse( Contracts::claim_cycle( $billed->get_id(), CycleStatus::PENDING, 900 ), 'A stale read loses.' );
+ $this->assertTrue( Contracts::claim_cycle( $billed->get_id(), CycleStatus::BILLED, 900 ), 'The caller names the status it read.' );
+ }
+
+ /**
+ * @testdox transition_cycle settles a pending cycle billed with its order, and a lost compare-and-set returns false.
+ */
+ public function test_transition_cycle_settles_and_loses_a_stale_read(): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+ $this->assertTrue( Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array( 'order_id' => 77 ) ) );
+ $this->assertFalse( Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::FAILED ), 'A caller that read pending loses.' );
+
+ $stored = $this->cycle( $id, $cycle->get_id() );
+ $this->assertSame( CycleStatus::BILLED, $stored->get_status()->get_value() );
+ $this->assertSame( 77, $stored->get_order_id() );
+ }
+
+ /**
+ * @testdox transition_cycle attaches an order under a claim, fails with a reason, and retries with a new claim.
+ */
+ public function test_transition_cycle_order_reason_and_claim(): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle(
+ $id,
+ $this->cycle_args(
+ array(
+ 'status' => CycleStatus::PENDING,
+ 'claim_seconds' => 900,
+ )
+ )
+ );
+ $cycle_id = $cycle->get_id();
+
+ $this->assertTrue(
+ Contracts::transition_cycle(
+ $cycle_id,
+ CycleStatus::PENDING,
+ CycleStatus::PENDING,
+ array(
+ 'order_id' => 77,
+ 'claim_seconds' => 900,
+ )
+ )
+ );
+ $attached = $this->cycle( $id, $cycle_id );
+ $this->assertSame( 77, $attached->get_order_id() );
+ $this->assertNotNull( $attached->get_claimed_until_gmt(), 'The claim is kept while attaching the order.' );
+
+ $this->assertTrue( Contracts::transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'reason' => 'payment-failed' ) ) );
+ $failed = Contracts::get_cycles( $id, 1 )[0];
+ $this->assertSame( CycleStatus::FAILED, $failed->get_status() );
+ $this->assertSame( 'payment-failed', $failed->get_reason() );
+ $this->assertSame( 77, $failed->get_order_id(), 'An omitted order_id keeps the stored one.' );
+ $this->assertNull( $this->cycle( $id, $cycle_id )->get_claimed_until_gmt(), 'No claim_seconds clears the claim.' );
+
+ $this->assertTrue( Contracts::transition_cycle( $cycle_id, CycleStatus::FAILED, CycleStatus::PENDING, array( 'claim_seconds' => 900 ) ) );
+ $this->assertFalse( Contracts::claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The retrier holds the claim.' );
+
+ $this->assertTrue( Contracts::transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array( 'reason' => null ) ) );
+ $this->assertNull( Contracts::get_cycles( $id, 1 )[0]->get_reason(), 'A null reason clears it.' );
+ }
+
+ /**
+ * @testdox transition_cycle rejects an unregistered target status and ignores unknown keys with a notice.
+ */
+ public function test_transition_cycle_rejects_bad_input(): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+ try {
+ Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, 'nonsense' );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertSame( CycleStatus::PENDING, Contracts::get_cycles( $id, 1 )[0]->get_status() );
+ }
+
+ $this->setExpectedIncorrectUsage( Contracts::class . '::transition_cycle' );
+ $this->assertTrue( Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array( 'status' => 'x' ) ) );
+ }
+
+ /**
+ * @testdox transition_cycle rejects an invalid argument value and leaves the cycle unchanged.
+ * @dataProvider provide_invalid_transition_args
+ *
+ * @param array<string, mixed> $args Transition args.
+ */
+ public function test_transition_cycle_rejects_invalid_argument_values( array $args ): void {
+ $id = $this->contract_with_currency();
+ $cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+ try {
+ Contracts::transition_cycle( $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, $args );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $stored = $this->cycle( $id, $cycle->get_id() );
+ $this->assertSame( CycleStatus::PENDING, $stored->get_status()->get_value() );
+ $this->assertNull( $stored->get_order_id() );
+ $this->assertNull( $stored->get_reason() );
+ }
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_transition_args(): array {
+ return array(
+ 'zero claim' => array( array( 'claim_seconds' => 0 ) ),
+ 'negative claim' => array( array( 'claim_seconds' => -1 ) ),
+ 'non-numeric claim' => array( array( 'claim_seconds' => 'x' ) ),
+ 'zero order id' => array( array( 'order_id' => 0 ) ),
+ 'negative order id' => array( array( 'order_id' => -5 ) ),
+ 'non-string reason' => array( array( 'reason' => array( 'payment-failed' ) ) ),
+ 'numeric reason' => array( array( 'reason' => 42 ) ),
+ );
+ }
+
+ /**
+ * @testdox find_cycles_by_order returns the cycles of every contract linked to an order, newest first.
+ */
+ public function test_find_cycles_by_order_spans_contracts_newest_first(): void {
+ $first = $this->contract_with_currency();
+ $second = $this->contract_with_currency();
+ $older = $this->add_cycle( $first, $this->cycle_args( array( 'order_id' => 77 ) ) );
+ $newer = $this->add_cycle( $second, $this->cycle_args( array( 'order_id' => 77 ) ) );
+ $this->add_cycle( $first, $this->cycle_args( array( 'order_id' => 88 ) ) );
+
+ $cycles = Contracts::find_cycles_by_order( 77 );
+
+ $this->assertSame(
+ array( $newer->get_id(), $older->get_id() ),
+ array_map(
+ static function ( CycleView $cycle ): int {
+ return $cycle->get_id();
+ },
+ $cycles
+ )
+ );
+ $this->assertSame( array(), Contracts::find_cycles_by_order( 99 ) );
+ }
+
+ /**
+ * @testdox get_cycles pages with an offset, newest first.
+ */
+ public function test_get_cycles_pages_with_an_offset(): void {
+ $id = $this->contract_with_currency();
+ $one = $this->add_cycle( $id, $this->cycle_args() );
+ $two = $this->add_cycle( $id, $this->cycle_args() );
+ $three = $this->add_cycle( $id, $this->cycle_args() );
+
+ $ids = static function ( array $cycles ): array {
+ return array_map(
+ static function ( CycleView $cycle ): int {
+ return $cycle->get_id();
+ },
+ $cycles
+ );
+ };
+
+ $this->assertSame( array( $three->get_id(), $two->get_id() ), $ids( Contracts::get_cycles( $id, 2 ) ) );
+ $this->assertSame( array( $one->get_id() ), $ids( Contracts::get_cycles( $id, 2, 2 ) ) );
+ }
+
+ /**
+ * Append a cycle through the facade, asserting the append won.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, mixed> $args Cycle fields.
+ */
+ private function add_cycle( int $contract_id, array $args ): CycleView {
+ $view = Contracts::add_cycle( $contract_id, $args );
+ $this->assertInstanceOf( CycleView::class, $view );
+
+ return $view;
+ }
+
+ /**
+ * Move a cycle's claim into the past.
+ *
+ * @param int $cycle_id Cycle id.
+ */
+ private function expire_claim( int $cycle_id ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ), array( 'claimed_until' => gmdate( 'Y-m-d H:i:s', time() - 60 ) ), array( 'id' => $cycle_id ) );
+ }
+
public function test_meta_keeps_several_values_under_one_key(): void {
$id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PaymentsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PaymentsTest.php
new file mode 100644
index 00000000000..9d7f8e34706
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PaymentsTest.php
@@ -0,0 +1,275 @@
+<?php
+/**
+ * Integration tests for the public Payments broker.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use RuntimeException;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Payments;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Payments
+ */
+class PaymentsTest extends EngineIntegrationTestCase {
+
+ private const GATEWAY = 'cod';
+
+ private const CHARGE_HOOK = 'woocommerce_subscriptions_engine_scheduled_payment_cod';
+
+ private const REUSABLE_FILTER = 'woocommerce_subscriptions_engine_requires_reusable_payment_method';
+
+ /**
+ * The order argument of each scheduled-payment action call, in order.
+ *
+ * @var array<int, mixed>
+ */
+ private $charges = array();
+
+ public function set_up(): void {
+ parent::set_up();
+ $this->charges = array();
+ add_action(
+ self::CHARGE_HOOK,
+ function ( $order ): void {
+ $this->charges[] = $order;
+ }
+ );
+ }
+
+ /**
+ * @testdox gateway_supports follows the gateway's supports(), including the core filter.
+ */
+ public function test_gateway_supports_reads_the_gateway_feature(): void {
+ $this->assertFalse( Payments::gateway_supports( self::GATEWAY ), 'A core gateway does not support engine charges by default.' );
+
+ $this->support_engine_charges( self::GATEWAY );
+
+ $this->assertTrue( Payments::gateway_supports( self::GATEWAY ) );
+ $this->assertFalse( Payments::gateway_supports( 'bacs' ), 'Only the gateway that declared the feature.' );
+ }
+
+ /**
+ * @testdox gateway_supports with features needs the engine feature and every listed one.
+ */
+ public function test_gateway_supports_checks_the_listed_features_with_the_engine_feature(): void {
+ $this->support_feature( self::GATEWAY, 'subscriptions' );
+
+ $this->assertFalse( Payments::gateway_supports( self::GATEWAY, 'subscriptions' ), 'A listed feature without the engine feature is not enough.' );
+
+ $this->support_engine_charges( self::GATEWAY );
+
+ $this->assertTrue( Payments::gateway_supports( self::GATEWAY, 'subscriptions' ) );
+ $this->assertTrue( Payments::gateway_supports( self::GATEWAY, array( 'subscriptions' ) ), 'A list works like a single feature.' );
+ $this->assertTrue( Payments::gateway_supports( self::GATEWAY, array() ), 'No listed feature checks the engine feature alone.' );
+ $this->assertFalse( Payments::gateway_supports( self::GATEWAY, array( 'subscriptions', 'subscription_payment_method_change' ) ), 'Every listed feature is needed.' );
+ $this->assertFalse( Payments::gateway_supports( 'bacs', 'subscriptions' ) );
+ }
+
+ /**
+ * @testdox gateway_supports is false for an empty or unknown gateway id.
+ */
+ public function test_gateway_supports_is_false_for_unknown_ids(): void {
+ $this->support_engine_charges( 'no-such-gateway' );
+
+ $this->assertFalse( Payments::gateway_supports( '' ) );
+ $this->assertFalse( Payments::gateway_supports( 'no-such-gateway' ) );
+ }
+
+ /**
+ * @testdox process_scheduled_payment fires the gateway's scheduled-payment action once with the order.
+ */
+ public function test_process_scheduled_payment_fires_once_for_a_supported_gateway(): void {
+ $this->support_engine_charges( self::GATEWAY );
+ $order = $this->make_order();
+
+ $this->assertTrue( Payments::process_scheduled_payment( $order ) );
+
+ $this->assertSame( array( $order ), $this->charges );
+ }
+
+ /**
+ * @testdox process_scheduled_payment leaves whether the order should be paid to the caller.
+ */
+ public function test_process_scheduled_payment_fires_whatever_the_order_state(): void {
+ $this->support_engine_charges( self::GATEWAY );
+ $paid = $this->make_order( self::GATEWAY, '12.50', 'processing' );
+ $free = $this->make_order( self::GATEWAY, '0' );
+
+ $this->assertTrue( Payments::process_scheduled_payment( $paid ) );
+ $this->assertTrue( Payments::process_scheduled_payment( $free ) );
+
+ $this->assertSame( array( $paid, $free ), $this->charges );
+ }
+
+ /**
+ * @testdox process_scheduled_payment does not fire, and logs a warning, when the order's gateway does not support the engine.
+ *
+ * @dataProvider provide_unsupported_payment_methods
+ *
+ * @param string $method Order payment method.
+ */
+ public function test_process_scheduled_payment_does_not_fire_without_engine_support( string $method ): void {
+ $order = $this->make_order( $method );
+
+ $warnings = $this->capture_engine_log(
+ 'warning',
+ array( 'order_id' => $order->get_id() ),
+ function () use ( $order ): void {
+ $this->assertFalse( Payments::process_scheduled_payment( $order ) );
+ }
+ );
+
+ $this->assertSame( array(), $this->charges );
+ $this->assertNotSame( array(), $warnings, 'The unsent payment is logged.' );
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_unsupported_payment_methods(): array {
+ return array(
+ 'unsupported gateway' => array( self::GATEWAY ),
+ 'no payment method' => array( '' ),
+ );
+ }
+
+ /**
+ * @testdox A throwing gateway callback is logged with the order id and process_scheduled_payment returns false.
+ */
+ public function test_a_throwing_gateway_is_logged(): void {
+ $this->support_engine_charges( self::GATEWAY );
+ add_action(
+ self::CHARGE_HOOK,
+ static function (): void {
+ throw new RuntimeException( 'Gateway failure.' );
+ }
+ );
+ $order = $this->make_order();
+
+ $errors = $this->capture_engine_log(
+ 'error',
+ array( 'order_id' => $order->get_id() ),
+ function () use ( $order ): void {
+ $this->assertFalse( Payments::process_scheduled_payment( $order ), 'A gateway that threw is not a payment in flight.' );
+ }
+ );
+
+ $this->assertNotSame( array(), $errors );
+ }
+
+ /**
+ * @testdox process_scheduled_payment returns false, and logs an error, when a supported gateway has no callback on its action.
+ */
+ public function test_a_supported_gateway_without_a_callback_is_logged(): void {
+ $this->support_engine_charges( self::GATEWAY );
+ remove_all_actions( self::CHARGE_HOOK );
+ $order = $this->make_order();
+
+ $errors = $this->capture_engine_log(
+ 'error',
+ array( 'order_id' => $order->get_id() ),
+ function () use ( $order ): void {
+ $this->assertFalse( Payments::process_scheduled_payment( $order ), 'Nothing was sent for payment.' );
+ }
+ );
+
+ $this->assertNotSame( array(), $errors );
+ }
+
+ /**
+ * @testdox requires_reusable_payment_method is false by default and casts the filter answer to bool.
+ */
+ public function test_requires_reusable_payment_method_answers(): void {
+ $this->assertFalse( Payments::requires_reusable_payment_method() );
+
+ add_filter( self::REUSABLE_FILTER, '__return_true' );
+ $this->assertTrue( Payments::requires_reusable_payment_method() );
+ remove_filter( self::REUSABLE_FILTER, '__return_true' );
+
+ $answer = 'yes';
+ $filter = static function () use ( &$answer ) {
+ return $answer;
+ };
+ add_filter( self::REUSABLE_FILTER, $filter );
+ $this->assertTrue( Payments::requires_reusable_payment_method(), 'A truthy answer counts as true.' );
+
+ $answer = 0;
+ $this->assertFalse( Payments::requires_reusable_payment_method(), 'A falsy answer counts as false.' );
+ }
+
+ /**
+ * @testdox requires_reusable_payment_method passes the order (or null for the cart) to the filter.
+ */
+ public function test_requires_reusable_payment_method_passes_the_order(): void {
+ $seen = array();
+ add_filter(
+ self::REUSABLE_FILTER,
+ static function ( $requires, $order ) use ( &$seen ) {
+ $seen[] = $order;
+ return $requires;
+ },
+ 10,
+ 2
+ );
+ $order = $this->make_order();
+
+ Payments::requires_reusable_payment_method( $order );
+ Payments::requires_reusable_payment_method();
+
+ $this->assertSame( array( $order, null ), $seen );
+ }
+
+ /**
+ * Declare the engine feature for a gateway through core's supports filter.
+ *
+ * @param string $gateway_id Gateway id.
+ */
+ private function support_engine_charges( string $gateway_id ): void {
+ $this->support_feature( $gateway_id, Payments::GATEWAY_FEATURE );
+ }
+
+ /**
+ * Declare a gateway feature through core's supports filter.
+ *
+ * @param string $gateway_id Gateway id.
+ * @param string $supported Feature.
+ */
+ private function support_feature( string $gateway_id, string $supported ): void {
+ add_filter(
+ 'woocommerce_payment_gateway_supports',
+ static function ( $supports, $feature, $gateway ) use ( $gateway_id, $supported ) {
+ if ( $supported === $feature && is_object( $gateway ) && isset( $gateway->id ) && $gateway_id === $gateway->id ) {
+ return true;
+ }
+ return $supports;
+ },
+ 10,
+ 3
+ );
+ }
+
+ /**
+ * Create a saved order.
+ *
+ * @param string $method Payment method.
+ * @param string $total Total.
+ * @param string $status Status.
+ */
+ private function make_order( string $method = self::GATEWAY, string $total = '12.50', string $status = 'pending' ): WC_Order {
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( $method );
+ $order->set_total( $total );
+ $order->set_status( $status );
+ $order->save();
+
+ return $order;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
deleted file mode 100644
index 4b39432aed5..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ /dev/null
@@ -1,241 +0,0 @@
-<?php
-/**
- * Integration tests for the interim Subscriptions renewal facade.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
-
-use EngineIntegrationTestCase;
-use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions
- */
-class SubscriptionsTest extends EngineIntegrationTestCase {
-
- /**
- * Gateway id used for the renewal charge - declares `recurring` and completes
- * the charge inline (the dummy-gateway shape), matching the real gateway used in CI.
- */
- private const GATEWAY = 'dummy';
-
- public function set_up(): void {
- parent::set_up();
- GatewayCapabilities::reset();
- $this->approve_charges_for( self::GATEWAY );
- }
-
- public function tear_down(): void {
- GatewayCapabilities::reset();
- parent::tear_down();
- }
-
- /**
- * Sign up a contract through the contracts facade (cycle 1 billed). The monthly plan's
- * cadence is frozen onto the contract's plan snapshot at signup.
- *
- * @param int $customer_id Owning customer id; 0 gives the order a new customer.
- * @return Contract The persisted contract with cycle 1 billed.
- */
- private function sign_up_contract( int $customer_id = 0 ): Contract {
- $plan = $this->plan_view( $this->make_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' );
- if ( $customer_id > 0 ) {
- $order->set_customer_id( $customer_id );
- }
- $order->save();
-
- $contract = ( new ContractRepository() )->find( $this->sign_up_from_order( $order, $plan ) );
- $this->assertInstanceOf( Contract::class, $contract );
-
- return $contract;
- }
-
- /**
- * @testdox get_related_orders returns the contract's linked orders (the origin order).
- */
- public function test_get_related_orders_returns_the_linked_orders(): void {
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $orders = Subscriptions::get_related_orders( $contract_id );
-
- $this->assertCount( 1, $orders );
- $this->assertInstanceOf( WC_Order::class, $orders[0] );
- $this->assertSame( $contract->get_origin_order_id(), $orders[0]->get_id() );
- }
-
- /**
- * @testdox get_related_orders lists an origin order that also carries the contract meta once.
- */
- public function test_get_related_orders_lists_a_meta_tagged_origin_once(): void {
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $origin = wc_get_order( (int) $contract->get_origin_order_id() );
- $this->assertInstanceOf( WC_Order::class, $origin );
- $origin->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
- $origin->save();
-
- $orders = Subscriptions::get_related_orders( $contract_id );
-
- $this->assertCount( 1, $orders );
- $this->assertSame( $origin->get_id(), $orders[0]->get_id() );
- }
-
- /**
- * @testdox get_related_orders is empty for a contract with no linked orders.
- */
- public function test_get_related_orders_is_empty_when_none_are_linked(): void {
- $this->assertSame( array(), Subscriptions::get_related_orders( 987654 ) );
- }
-
- /**
- * @testdox get_related_orders windows with limit/offset, newest first.
- */
- public function test_get_related_orders_windows_with_limit_and_offset(): void {
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Link three renewal orders backdated 1-3 days before the origin (created
- // "now"), so the full set is 4 orders newest-first: origin, renewal1,
- // renewal2, renewal3.
- $renewal_ids = array();
- foreach ( array( 3, 2, 1 ) as $days_ago ) {
- $order = wc_create_order();
- $this->assertInstanceOf( WC_Order::class, $order );
- $order->set_date_created( gmdate( 'Y-m-d H:i:s', time() - ( $days_ago * DAY_IN_SECONDS ) ) );
- $order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
- $order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
- $order->save();
- $renewal_ids[ $days_ago ] = $order->get_id();
- }
-
- $all = Subscriptions::get_related_orders( $contract_id );
- $this->assertCount( 4, $all, 'The default window stays "all".' );
-
- $first_page = Subscriptions::get_related_orders( $contract_id, 2, 0 );
- $this->assertCount( 2, $first_page );
- $this->assertSame( $contract->get_origin_order_id(), $first_page[0]->get_id(), 'Newest linked order first.' );
- $this->assertSame( $renewal_ids[1], $first_page[1]->get_id() );
-
- $second_page = Subscriptions::get_related_orders( $contract_id, 2, 2 );
- $this->assertCount( 2, $second_page );
- $this->assertSame( $renewal_ids[2], $second_page[0]->get_id() );
- $this->assertSame( $renewal_ids[3], $second_page[1]->get_id() );
-
- $past_the_end = Subscriptions::get_related_orders( $contract_id, 2, 4 );
- $this->assertSame( array(), $past_the_end );
-
- // A zero limit means none - never WP_Query's posts-per-page default.
- $this->assertSame( array(), Subscriptions::get_related_orders( $contract_id, 0 ) );
- }
-
- /**
- * @testdox get_related_orders bounds the linked-order query to the page and still pages like an unbounded read.
- */
- public function test_get_related_orders_bounds_the_query_to_the_page(): void {
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Five renewal orders dated after the origin, newest last created.
- foreach ( array( 1, 2, 3, 4, 5 ) as $days_ahead ) {
- $order = wc_create_order();
- $this->assertInstanceOf( WC_Order::class, $order );
- $order->set_date_created( gmdate( 'Y-m-d H:i:s', time() + ( $days_ahead * DAY_IN_SECONDS ) ) );
- $order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
- $order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
- $order->save();
- }
-
- $all_ids = array_map(
- static function ( WC_Order $order ): int {
- return $order->get_id();
- },
- Subscriptions::get_related_orders( $contract_id )
- );
- $this->assertCount( 6, $all_ids );
-
- $limits = array();
- $record = static function ( array $args ) use ( &$limits ): array {
- $limits[] = $args['limit'] ?? null;
- return $args;
- };
- add_filter( 'woocommerce_order_query_args', $record );
-
- try {
- foreach ( array( 0, 2, 4 ) as $offset ) {
- $page_ids = array_map(
- static function ( WC_Order $order ): int {
- return $order->get_id();
- },
- Subscriptions::get_related_orders( $contract_id, 2, $offset )
- );
- $this->assertSame( array_slice( $all_ids, $offset, 2 ), $page_ids, "Page at offset {$offset} matches the unbounded read." );
- }
- } finally {
- remove_filter( 'woocommerce_order_query_args', $record );
- }
-
- $this->assertSame( array( 2, 4, 6 ), $limits, 'The linked-order query is bounded to offset + limit.' );
- }
-
- /**
- * @testdox renew_now returns null for an unknown contract.
- */
- public function test_renew_now_unknown_contract_returns_null(): void {
- $this->assertNull( Subscriptions::renew_now( 999999 ) );
- }
-
- /**
- * @testdox A purchased contract renews through the facade.
- */
- public function test_buy_then_renew_through_the_facade(): void {
- // Buy: signup builds cycle 1 (billed).
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
- // Monthly plan, paid 2026-01-15: first renewal is one month out.
- $this->assertSame( '2026-02-15 00:00:00', $contract->get_next_payment_gmt() );
-
- // Renew: advance the chain a cycle through the facade.
- $renewal_order = Subscriptions::renew_now( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertTrue( $renewal_order->is_paid() );
-
- $history = Contracts::get_cycles( $contract_id );
- $this->assertCount( 2, $history );
-
- // Newest first: cycle 2 is billed, linked to the renewal order.
- $cycle_two = $history[0];
- $this->assertSame( 2, $cycle_two->get_count() );
- $this->assertSame( CycleStatus::BILLED, $cycle_two->get_status() );
- $this->assertSame( $renewal_order->get_id(), $cycle_two->get_order_id() );
-
- // The schedule advanced one cadence (cycle 1 ended 2026-02-15 + 1 month).
- $after_renew = Contracts::get( $contract_id );
- $this->assertInstanceOf( ContractView::class, $after_renew );
- $this->assertSame( '2026-03-15 00:00:00', $after_renew->get_next_payment_gmt() );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index 8efba72e1a6..342321263bf 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -16,7 +16,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
/**
* Engine integration test case.
@@ -29,71 +28,29 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
protected const PLAN_OWNER = 'engine-tests';
/**
- * Gateway ids wired with an approving scheduled-payment handler, to unhook on teardown.
- *
- * @var array<int, string>
+ * Return the session's database clock to the server clock after each test.
*/
- private $approved_gateways = array();
-
public function tear_down(): void {
- foreach ( $this->approved_gateways as $gateway ) {
- remove_all_actions( 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway );
- }
- $this->approved_gateways = array();
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query( 'SET timestamp = DEFAULT' );
parent::tear_down();
}
/**
- * Declare `recurring` for `$gateway` and wire an inline approving handler: it marks the
- * renewal order paid synchronously (the dummy-gateway shape), so the money-path reads a
- * paid order immediately after the charge is attempted. Unhooked automatically on teardown.
- *
- * @param string $gateway Gateway id to approve charges for.
- */
- protected function approve_charges_for( string $gateway ): void {
- GatewayCapabilities::declare( $gateway, array( GatewayCapabilities::RECURRING ) );
-
- add_action(
- 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway,
- static function ( $amount, $renewal_order ): void {
- unset( $amount );
- if ( $renewal_order instanceof WC_Order && $renewal_order->needs_payment() ) {
- $renewal_order->payment_complete();
- }
- },
- 10,
- 2
- );
-
- $this->approved_gateways[] = $gateway;
- }
-
- /**
- * Declare `recurring` for `$gateway` and wire an inline declining handler: it moves the
- * renewal order to `failed` (a hard decline the gateway reports synchronously), so the
- * money-path settles the cycle `failed` - as opposed to a gateway that leaves the order
- * un-paid-and-not-failed, which is treated as an async charge still awaiting confirmation.
- * Unhooked automatically on teardown.
+ * Pin the session's database clock: `UTC_TIMESTAMP()` returns `$utc` until the next pin
+ * or the end of the test. Dispatch claims and cycle claims are measured on this clock.
*
- * @param string $gateway Gateway id to fail charges for.
+ * @param string $utc Moment (UTC, `Y-m-d H:i:s`).
*/
- protected function fail_charges_for( string $gateway ): void {
- GatewayCapabilities::declare( $gateway, array( GatewayCapabilities::RECURRING ) );
+ protected function pin_db_clock( string $utc ): void {
+ global $wpdb;
- add_action(
- 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway,
- static function ( $amount, $renewal_order ): void {
- unset( $amount );
- if ( $renewal_order instanceof WC_Order ) {
- $renewal_order->update_status( 'failed', 'Gateway declined the recurring charge.' );
- }
- },
- 10,
- 2
- );
+ $timestamp = ( new DateTimeImmutable( $utc, new DateTimeZone( 'UTC' ) ) )->getTimestamp();
- $this->approved_gateways[] = $gateway;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query( $wpdb->prepare( 'SET timestamp = %d', $timestamp ) );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php
new file mode 100644
index 00000000000..4e94bba7533
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Dispatch/DueDispatcherTest.php
@@ -0,0 +1,524 @@
+<?php
+/**
+ * Integration tests for the DueDispatcher (find, claim, notify).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Dispatch;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use EngineIntegrationTestCase;
+use RuntimeException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher
+ */
+class DueDispatcherTest extends EngineIntegrationTestCase {
+
+ private const OWNER = 'engine-tests';
+
+ private const SCHEDULE_CHECK_OPTION = 'woocommerce_subscriptions_engine_due_dispatch_scheduled_check';
+
+ /**
+ * The System Under Test.
+ *
+ * @var DueDispatcher
+ */
+ private $sut;
+
+ /**
+ * Calls the recording handler received, in order.
+ *
+ * @var array<int, array{contract: ContractView, context: array<string, mixed>}>
+ */
+ private $calls = array();
+
+ public function set_up(): void {
+ parent::set_up();
+ ConsumerRegistry::reset();
+ $this->calls = array();
+ $this->sut = new DueDispatcher();
+ }
+
+ public function tear_down(): void {
+ as_unschedule_all_actions( DueDispatcher::HOOK, array(), DueDispatcher::GROUP );
+ ConsumerRegistry::reset();
+ parent::tear_down();
+ }
+
+ /**
+ * @testdox Overlapping ticks at the same moment notify a due contract once.
+ */
+ public function test_overlapping_ticks_notify_once(): void {
+ $this->register_recording_handler();
+ $this->create_due_contract();
+
+ $this->assertSame( 1, $this->sut->run_batch( $this->now() ) );
+ $this->assertSame( 0, $this->sut->run_batch( $this->now() ), 'The second tick finds the contract claimed.' );
+ $this->assertCount( 1, $this->calls );
+ }
+
+ /**
+ * @testdox A contract another dispatcher already claimed is not selected by the scan.
+ */
+ public function test_a_contract_claimed_before_the_scan_is_not_notified(): void {
+ $this->register_recording_handler();
+ $id = $this->create_due_contract();
+
+ $this->assertTrue( ( new ContractRepository() )->claim_contract( $id, '2026-02-15 00:00:00', DueDispatcher::CLAIM_TTL_SECONDS ) );
+
+ $this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
+ $this->assertSame( array(), $this->calls );
+ }
+
+ /**
+ * @testdox A contract whose claim is lost between the scan and the claim is skipped.
+ */
+ public function test_a_claim_lost_after_the_scan_is_skipped(): void {
+ $first = $this->create_due_contract( '2026-02-14 00:00:00' );
+ $second = $this->create_due_contract( '2026-02-15 00:00:00' );
+ ConsumerRegistry::register(
+ self::OWNER,
+ array(
+ 'contract_due_handler' => function ( ContractView $contract, array $context ) use ( $first, $second ): void {
+ $this->calls[] = array(
+ 'contract' => $contract,
+ 'context' => $context,
+ );
+ if ( $first === $contract->get_id() ) {
+ // Another writer moves the second contract after the scan read it.
+ Contracts::update( $second, array( 'next_payment_gmt' => '2026-02-14 12:00:00' ) );
+ }
+ },
+ )
+ );
+
+ $this->assertSame( 1, $this->sut->run_batch( $this->now() ), 'Only the first contract is notified.' );
+ $this->assertCount( 1, $this->calls );
+ $this->assertSame( $first, $this->calls[0]['contract']->get_id() );
+ $this->assertNull( $this->stored_claim( $second ), 'The lost claim wrote nothing.' );
+ }
+
+ /**
+ * @testdox A handler that leaves the due moment is re-notified only after the claim expires.
+ */
+ public function test_an_idle_handler_is_renotified_after_the_claim_expires(): void {
+ $this->register_recording_handler();
+ $this->create_due_contract();
+
+ $this->pin_db_clock( '2026-02-15 00:00:00' );
+ $this->sut->run_batch( $this->now() );
+ $this->pin_db_clock( '2026-02-15 00:14:59' );
+ $this->sut->run_batch( $this->now() );
+ $this->assertCount( 1, $this->calls, 'Not re-notified within the claim TTL.' );
+
+ $this->pin_db_clock( '2026-02-15 00:15:00' );
+ $this->sut->run_batch( $this->now() );
+ $this->assertCount( 2, $this->calls, 'Re-notified once the claim expired on the database clock.' );
+ }
+
+ /**
+ * @testdox A handler that rewrites the due moment releases the claim and is notified on the next tick.
+ */
+ public function test_moving_the_due_moment_releases_the_claim(): void {
+ ConsumerRegistry::register(
+ self::OWNER,
+ array(
+ 'contract_due_handler' => function ( ContractView $contract, array $context ): void {
+ $this->calls[] = array(
+ 'contract' => $contract,
+ 'context' => $context,
+ );
+ Contracts::update( $contract->get_id(), array( 'next_payment_gmt' => '2026-02-16 00:00:00' ) );
+ },
+ )
+ );
+ $this->create_due_contract();
+
+ $this->sut->run_batch( $this->now()->modify( '+1 day' ) );
+ $this->sut->run_batch( $this->now()->modify( '+1 day +1 second' ) );
+
+ $this->assertCount( 2, $this->calls, 'The new due moment is notified without waiting for the claim TTL.' );
+ $this->assertEquals( new DateTimeImmutable( '2026-02-16 00:00:00', new DateTimeZone( 'UTC' ) ), $this->calls[1]['context']['due_at'] );
+ }
+
+ /**
+ * @testdox A handler that clears the due moment is never notified again.
+ */
+ public function test_clearing_the_due_moment_stops_notifications(): void {
+ ConsumerRegistry::register(
+ self::OWNER,
+ array(
+ 'contract_due_handler' => function ( ContractView $contract, array $context ): void {
+ $this->calls[] = array(
+ 'contract' => $contract,
+ 'context' => $context,
+ );
+ Contracts::update( $contract->get_id(), array( 'next_payment_gmt' => null ) );
+ },
+ )
+ );
+ $this->create_due_contract();
+
+ $this->sut->run_batch( $this->now() );
+ $this->sut->run_batch( $this->now()->modify( '+1 day' ) );
+
+ $this->assertCount( 1, $this->calls );
+ }
+
+ /**
+ * @testdox A throwing handler is logged, keeps its claim and does not stop the batch.
+ */
+ public function test_a_throwing_handler_is_logged_and_the_batch_continues(): void {
+ $failing = $this->create_due_contract( '2026-02-14 00:00:00' );
+ $healthy = $this->create_due_contract( '2026-02-15 00:00:00' );
+ ConsumerRegistry::register(
+ self::OWNER,
+ array(
+ 'contract_due_handler' => function ( ContractView $contract, array $context ) use ( $failing ): void {
+ if ( $failing === $contract->get_id() ) {
+ throw new RuntimeException( 'Handler failure.' );
+ }
+ $this->calls[] = array(
+ 'contract' => $contract,
+ 'context' => $context,
+ );
+ },
+ )
+ );
+
+ $errors = $this->capture_engine_log(
+ 'error',
+ array( 'contract_id' => $failing ),
+ function (): void {
+ $this->assertSame( 1, $this->sut->run_batch( $this->now() ), 'Only the healthy contract counts as notified.' );
+ }
+ );
+
+ $this->assertNotSame( array(), $errors, 'The failure is logged with the contract id.' );
+ $this->assertCount( 1, $this->calls );
+ $this->assertSame( $healthy, $this->calls[0]['contract']->get_id() );
+ $this->assertNotNull( $this->stored_claim( $failing ), 'The failing contract keeps its claim (back-off).' );
+ }
+
+ /**
+ * @testdox An owner registered without a handler is not dispatched and its contracts stay untouched.
+ */
+ public function test_an_owner_without_a_handler_is_not_dispatched(): void {
+ ConsumerRegistry::register( self::OWNER );
+ $id = $this->create_due_contract();
+
+ $this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
+ $this->assertNull( $this->stored_claim( $id ), 'No claim is taken on a contract nobody handles.' );
+ }
+
+ /**
+ * @testdox Contracts of an unregistered owner, or with no owner, are not dispatched.
+ */
+ public function test_unregistered_and_missing_owners_are_not_dispatched(): void {
+ global $wpdb;
+
+ $this->register_recording_handler();
+ $foreign = $this->create_due_contract( '2026-02-15 00:00:00', array( 'extension_slug' => 'other-ext' ) );
+ $no_owner = $this->create_due_contract();
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'extension_slug' => null ), array( 'id' => $no_owner ) );
+
+ $this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
+ $this->assertSame( array(), $this->calls );
+ $this->assertNull( $this->stored_claim( $foreign ) );
+ }
+
+ /**
+ * @testdox Each due contract goes to its own owner's handler when several owners have one.
+ */
+ public function test_each_contract_goes_to_its_owner_s_handler(): void {
+ $received = array(
+ 'owner-a' => array(),
+ 'owner-b' => array(),
+ );
+ foreach ( array_keys( $received ) as $owner ) {
+ ConsumerRegistry::register(
+ $owner,
+ array(
+ 'contract_due_handler' => function ( ContractView $contract ) use ( &$received, $owner ): void {
+ $received[ $owner ][] = $contract->get_id();
+ },
+ )
+ );
+ }
+ $a = $this->create_due_contract( '2026-02-15 00:00:00', array( 'extension_slug' => 'owner-a' ) );
+ $b = $this->create_due_contract( '2026-02-15 00:00:00', array( 'extension_slug' => 'owner-b' ) );
+
+ $this->assertSame( 2, $this->sut->run_batch( $this->now() ) );
+ $this->assertSame( array( $a ), $received['owner-a'] );
+ $this->assertSame( array( $b ), $received['owner-b'] );
+ }
+
+ /**
+ * @testdox A contract whose schedule a gateway or extension keeps is not dispatched.
+ */
+ public function test_a_contract_with_another_schedule_source_is_not_dispatched(): void {
+ $this->register_recording_handler();
+ $id = $this->create_due_contract( '2026-02-15 00:00:00', array( 'schedule_source' => 'my_gateway' ) );
+
+ $this->assertSame( 0, $this->sut->run_batch( $this->now() ) );
+ $this->assertSame( array(), $this->calls );
+ $this->assertNull( $this->stored_claim( $id ), 'No claim is taken on a contract the engine does not schedule.' );
+ }
+
+ /**
+ * @testdox The handler receives the contract view with items and the stored due moment in UTC.
+ */
+ public function test_the_handler_receives_the_contract_view_and_due_at(): void {
+ $this->register_recording_handler();
+ $id = $this->create_due_contract();
+
+ $this->sut->run_batch( new DateTimeImmutable( '2026-02-15 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) );
+
+ $this->assertCount( 1, $this->calls );
+ $contract = $this->calls[0]['contract'];
+ $this->assertSame( $id, $contract->get_id() );
+ $this->assertCount( 1, $contract->get_items() ?? array(), 'The view carries the contract items.' );
+
+ $due_at = $this->calls[0]['context']['due_at'];
+ $this->assertInstanceOf( DateTimeImmutable::class, $due_at );
+ $this->assertSame( '2026-02-15 00:00:00', $due_at->format( 'Y-m-d H:i:s' ) );
+ $this->assertSame( 'UTC', $due_at->getTimezone()->getName() );
+ }
+
+ /**
+ * @testdox The dispatcher is status-blind: an on-hold contract with a due moment is notified.
+ */
+ public function test_an_on_hold_contract_with_a_due_moment_is_notified(): void {
+ $this->register_recording_handler();
+ $this->create_due_contract( '2026-02-15 00:00:00', array( 'status' => ContractStatus::ON_HOLD ) );
+
+ $this->assertSame( 1, $this->sut->run_batch( $this->now() ) );
+ $this->assertSame( ContractStatus::ON_HOLD, $this->calls[0]['contract']->get_status() );
+ }
+
+ /**
+ * @testdox The batch limit caps notifications per tick and a non-positive limit is a no-op.
+ */
+ public function test_batch_limit(): void {
+ $this->register_recording_handler();
+ $first = $this->create_due_contract( '2026-02-13 00:00:00' );
+ $this->create_due_contract( '2026-02-14 00:00:00' );
+ $this->create_due_contract( '2026-02-15 00:00:00' );
+
+ $this->assertSame( 0, $this->sut->run_batch( $this->now(), 0 ) );
+ $this->assertSame( 0, $this->sut->run_batch( $this->now(), -1 ) );
+ $this->assertNull( $this->stored_claim( $first ), 'A no-op tick claims nothing.' );
+
+ $this->assertSame( 1, $this->sut->run_batch( $this->now(), 1 ) );
+ $this->assertSame( $first, $this->calls[0]['contract']->get_id(), 'The oldest-due contract goes first.' );
+ $this->assertSame( 2, $this->sut->run_batch( $this->now() ) );
+ }
+
+ /**
+ * @testdox Contracts whose handler left the due moment in place sort behind a freshly due contract.
+ */
+ public function test_repeat_notifications_do_not_starve_a_fresh_due_contract(): void {
+ global $wpdb;
+
+ $this->register_recording_handler();
+ $stuck = array();
+ for ( $i = 0; $i < 3; $i++ ) {
+ $stuck[] = $this->create_due_contract( '2026-01-15 00:00:00' );
+ }
+ $fresh = $this->create_due_contract( '2026-02-15 00:00:00' );
+
+ // Every stuck contract was notified before and its claim has since expired.
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ foreach ( $stuck as $id ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( $table, array( 'claimed_until' => '2026-01-15 00:15:00' ), array( 'id' => $id ) );
+ }
+
+ // A batch smaller than the stuck backlog: the fresh contract still makes it in.
+ $this->assertSame( 2, $this->sut->run_batch( $this->now(), 2 ) );
+ $this->assertSame( $fresh, $this->calls[0]['contract']->get_id(), 'The never-notified contract goes first despite its later due moment.' );
+ }
+
+ /**
+ * @testdox Among notified contracts, the least recently notified goes first, so stuck older ones do not starve a later-due retry.
+ */
+ public function test_stuck_contracts_do_not_starve_a_later_due_retry(): void {
+ global $wpdb;
+
+ $this->register_recording_handler();
+ $stuck = array();
+ for ( $i = 0; $i < 3; $i++ ) {
+ $stuck[] = $this->create_due_contract( '2026-01-15 00:00:00' );
+ }
+ $retry = $this->create_due_contract( '2026-02-10 00:00:00' );
+
+ // The stuck contracts were re-notified recently; the retry's handler threw a while ago.
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ foreach ( $stuck as $id ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( $table, array( 'claimed_until' => '2026-02-14 00:15:00' ), array( 'id' => $id ) );
+ }
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( $table, array( 'claimed_until' => '2026-02-10 00:15:00' ), array( 'id' => $retry ) );
+
+ // A batch smaller than the stuck backlog: the retry still makes it in.
+ $this->assertSame( 2, $this->sut->run_batch( $this->now(), 2 ) );
+ $this->assertSame( $retry, $this->calls[0]['contract']->get_id(), 'The least recently notified contract goes first despite its later due moment.' );
+ }
+
+ /**
+ * @testdox A due handler registered after the boot-time check (which found none) schedules the scan itself.
+ */
+ public function test_a_handler_registered_after_the_boot_check_schedules_the_scan(): void {
+ $this->assertGreaterThan( 0, did_action( 'wp_loaded' ), 'The test runs after wp_loaded, like a late registration.' );
+ delete_option( self::SCHEDULE_CHECK_OPTION );
+
+ DueDispatcher::ensure_scheduled();
+ $this->assertFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ), 'The boot check found no handler.' );
+
+ $this->register_recording_handler();
+
+ $this->assertNotFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ), 'The late registration scheduled the scan.' );
+ }
+
+ /**
+ * @testdox ensure_scheduled schedules only when a handler exists, is idempotent, and unschedules when the last handler-owner leaves.
+ */
+ public function test_ensure_scheduled_follows_the_handler_owners(): void {
+ delete_option( self::SCHEDULE_CHECK_OPTION );
+
+ ConsumerRegistry::register( self::OWNER );
+ DueDispatcher::ensure_scheduled();
+ $this->assertFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ), 'No scan without a handler.' );
+
+ ConsumerRegistry::reset();
+ $this->register_recording_handler();
+ DueDispatcher::ensure_scheduled();
+ $this->assertNotFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ) );
+
+ delete_option( self::SCHEDULE_CHECK_OPTION );
+ DueDispatcher::ensure_scheduled();
+ $this->assertCount( 1, as_get_scheduled_actions( array( 'hook' => DueDispatcher::HOOK, 'group' => DueDispatcher::GROUP, 'status' => 'pending' ), 'ids' ), 'A second call does not schedule a duplicate.' ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound
+
+ ConsumerRegistry::reset();
+ DueDispatcher::ensure_scheduled();
+ $this->assertFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ), 'The scan is removed once no owner has a handler.' );
+ $this->assertFalse( get_option( self::SCHEDULE_CHECK_OPTION, false ) );
+ }
+
+ /**
+ * @testdox Unregistering the last owner with a due handler removes the scan right away.
+ */
+ public function test_unregistering_the_last_handler_owner_removes_the_scan(): void {
+ delete_option( self::SCHEDULE_CHECK_OPTION );
+ $this->register_recording_handler();
+ DueDispatcher::ensure_scheduled();
+ $this->assertNotFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ) );
+
+ ConsumerRegistry::unregister( self::OWNER );
+
+ $this->assertFalse( as_next_scheduled_action( DueDispatcher::HOOK, array(), DueDispatcher::GROUP ), 'No owner has a handler, so the scan is gone.' );
+ }
+
+ /**
+ * @testdox handle_tick runs a batch at the current moment.
+ */
+ public function test_handle_tick_routes_to_run_batch(): void {
+ $this->register_recording_handler();
+ $this->create_due_contract();
+
+ $this->sut->register_hooks();
+ do_action( DueDispatcher::HOOK ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ $this->assertCount( 1, $this->calls );
+ }
+
+ /**
+ * The scan moment the fixtures are due against.
+ */
+ private function now(): DateTimeImmutable {
+ return new DateTimeImmutable( '2026-02-15 00:00:00', new DateTimeZone( 'UTC' ) );
+ }
+
+ /**
+ * Register the test owner with a handler that records each call.
+ */
+ private function register_recording_handler(): void {
+ ConsumerRegistry::register(
+ self::OWNER,
+ array(
+ 'contract_due_handler' => function ( ContractView $contract, array $context ): void {
+ $this->calls[] = array(
+ 'contract' => $contract,
+ 'context' => $context,
+ );
+ },
+ )
+ );
+ }
+
+ /**
+ * Create an active contract with one item, due at `$next_payment_gmt`.
+ *
+ * @param string $next_payment_gmt Due moment (UTC).
+ * @param array<string, mixed> $overrides `Contracts::create()` fields to replace.
+ * @return int The contract id.
+ */
+ private function create_due_contract( string $next_payment_gmt = '2026-02-15 00:00:00', array $overrides = array() ): int {
+ $contract = Contracts::create(
+ array_merge(
+ array(
+ 'extension_slug' => self::OWNER,
+ 'status' => ContractStatus::ACTIVE,
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'start_gmt' => '2026-01-15 00:00:00',
+ 'next_payment_gmt' => $next_payment_gmt,
+ 'billing_total' => '19.99',
+ 'items' => array(
+ array(
+ 'item_name' => 'Coffee bag',
+ 'item_type' => 'line_item',
+ 'product_id' => 200,
+ 'quantity' => '1',
+ 'subtotal' => '19.99',
+ 'total' => '19.99',
+ ),
+ ),
+ ),
+ $overrides
+ )
+ );
+
+ return $contract->get_id();
+ }
+
+ /**
+ * The stored due-dispatch claim of a contract.
+ *
+ * @param int $id Contract id.
+ */
+ private function stored_claim( int $id ): ?string {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $claim = $wpdb->get_var( $wpdb->prepare( "SELECT claimed_until FROM {$table} WHERE id = %d", $id ) );
+
+ return null === $claim ? null : (string) $claim;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ConsumerRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ConsumerRegistryTest.php
new file mode 100644
index 00000000000..d5d998dc485
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Ownership/ConsumerRegistryTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Integration tests for the ConsumerRegistry (the set of engine-consuming extensions).
+ *
+ * Integration suite because invalid registrations raise `_doing_it_wrong()` notices.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Ownership;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry
+ */
+class ConsumerRegistryTest extends EngineIntegrationTestCase {
+
+ private const REGISTER = 'Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry::register';
+
+ public function set_up(): void {
+ parent::set_up();
+ ConsumerRegistry::reset();
+ }
+
+ public function tear_down(): void {
+ ConsumerRegistry::reset();
+ parent::tear_down();
+ }
+
+ /**
+ * @testdox No owner has a due handler when nothing is registered.
+ */
+ public function test_no_handler_owners_when_nothing_is_registered(): void {
+ $this->assertSame( array(), ConsumerRegistry::get_slugs_with_contract_due_handler() );
+ }
+
+ /**
+ * @testdox Registering a slug without configuration records no due handler.
+ */
+ public function test_register_records_a_consumer(): void {
+ ConsumerRegistry::register( 'lite' );
+
+ $this->assertNull( ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+ $this->assertSame( array(), ConsumerRegistry::get_slugs_with_contract_due_handler(), 'A slug without a handler is not a dispatch owner.' );
+ }
+
+ /**
+ * @testdox A callable due handler is stored and returned for its slug.
+ */
+ public function test_register_stores_a_callable_due_handler(): void {
+ $handler = static function (): void {};
+
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => $handler ) );
+
+ $this->assertSame( $handler, ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+ $this->assertSame( array( 'lite' ), ConsumerRegistry::get_slugs_with_contract_due_handler() );
+ }
+
+ /**
+ * @testdox Only slugs that registered a due handler are listed as dispatch owners.
+ */
+ public function test_slugs_with_due_handler_lists_only_handler_owners(): void {
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => static function (): void {} ) );
+ ConsumerRegistry::register( 'premium' );
+
+ $this->assertSame( array( 'lite' ), ConsumerRegistry::get_slugs_with_contract_due_handler() );
+ $this->assertNull( ConsumerRegistry::get_contract_due_handler( 'unknown' ), 'An unregistered slug has no handler.' );
+ }
+
+ /**
+ * @testdox A non-callable due handler raises a notice and is dropped; the slug still registers.
+ */
+ public function test_non_callable_due_handler_is_dropped(): void {
+ $this->setExpectedIncorrectUsage( self::REGISTER );
+
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => 'not_a_function_anywhere' ) );
+
+ $this->assertNull( ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+ $this->assertSame( array(), ConsumerRegistry::get_slugs_with_contract_due_handler() );
+
+ // The slug registered: a later registration is a repeat and does not take.
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => static function (): void {} ) );
+ $this->assertNull( ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+ }
+
+ /**
+ * @testdox An unknown configuration key raises a notice and is ignored.
+ */
+ public function test_unknown_config_key_is_ignored(): void {
+ $this->setExpectedIncorrectUsage( self::REGISTER );
+ $handler = static function (): void {};
+
+ ConsumerRegistry::register(
+ 'lite',
+ array(
+ 'contract_due_handler' => $handler,
+ 'renewal_handler' => $handler,
+ )
+ );
+
+ $this->assertSame( $handler, ConsumerRegistry::get_contract_due_handler( 'lite' ), 'Known keys still apply.' );
+ }
+
+ /**
+ * @testdox A repeat registration raises a notice and keeps the first registration.
+ */
+ public function test_repeat_registration_keeps_the_first(): void {
+ $this->setExpectedIncorrectUsage( self::REGISTER );
+ $first = static function (): void {};
+
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => $first ) );
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => static function (): void {} ) );
+ ConsumerRegistry::register( ' lite ' );
+
+ $this->assertSame( $first, ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+ $this->assertSame( array( 'lite' ), ConsumerRegistry::get_slugs_with_contract_due_handler() );
+ }
+
+ /**
+ * @testdox An empty or whitespace-only slug is ignored and a slug is stored trimmed.
+ */
+ public function test_register_ignores_blank_slugs_and_trims(): void {
+ $handler = static function (): void {};
+
+ ConsumerRegistry::register( '' );
+ ConsumerRegistry::register( " \t\n", array( 'contract_due_handler' => $handler ) );
+ $this->assertSame( array(), ConsumerRegistry::get_slugs_with_contract_due_handler(), 'A blank slug must not register.' );
+
+ ConsumerRegistry::register( ' woocommerce-subscriptions-lite ', array( 'contract_due_handler' => $handler ) );
+ $this->assertSame( array( 'woocommerce-subscriptions-lite' ), ConsumerRegistry::get_slugs_with_contract_due_handler(), 'The slug is stored trimmed.' );
+ $this->assertSame( $handler, ConsumerRegistry::get_contract_due_handler( ' woocommerce-subscriptions-lite ' ) );
+ }
+
+ /**
+ * @testdox Unregister removes only the named consumer and its handler.
+ */
+ public function test_unregister_removes_only_the_named_consumer(): void {
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => static function (): void {} ) );
+ ConsumerRegistry::register( 'premium', array( 'contract_due_handler' => static function (): void {} ) );
+
+ ConsumerRegistry::unregister( 'lite' );
+ $this->assertSame( array( 'premium' ), ConsumerRegistry::get_slugs_with_contract_due_handler(), 'Only the named consumer is removed.' );
+ $this->assertNull( ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+
+ ConsumerRegistry::unregister( 'unknown' );
+ $this->assertSame( array( 'premium' ), ConsumerRegistry::get_slugs_with_contract_due_handler(), 'An unknown slug is a no-op.' );
+
+ ConsumerRegistry::unregister( ' premium ' );
+ $this->assertSame( array(), ConsumerRegistry::get_slugs_with_contract_due_handler(), 'The slug is trimmed, matching register().' );
+ }
+
+ /**
+ * @testdox Reset clears every registration.
+ */
+ public function test_reset_clears_every_registration(): void {
+ ConsumerRegistry::register( 'lite', array( 'contract_due_handler' => static function (): void {} ) );
+ ConsumerRegistry::reset();
+
+ $this->assertSame( array(), ConsumerRegistry::get_slugs_with_contract_due_handler() );
+ $this->assertNull( ConsumerRegistry::get_contract_due_handler( 'lite' ) );
+ }
+}
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
deleted file mode 100644
index aa961e25cab..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
+++ /dev/null
@@ -1,255 +0,0 @@
-<?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\Api\Contracts;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-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 );
- Contracts::update(
- $id,
- array(
- 'status' => ContractStatus::ON_HOLD,
- 'next_payment_gmt' => null,
- )
- );
-
- $this->run_batch_at( self::FIRST_DUE );
- $this->run_batch_at( '2026-06-01 00:00:00' );
-
- $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 );
- Contracts::update(
- $id,
- array(
- 'status' => ContractStatus::PENDING_CANCELLATION,
- 'next_payment_gmt' => null,
- 'end_gmt' => self::FIRST_DUE,
- )
- );
-
- $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` through the contracts facade (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 = $this->plan_view( $this->make_plan( array( 'extension_slug' => $owner ) ) );
-
- $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();
-
- $id = $this->sign_up_from_order( $order, $plan );
- $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
deleted file mode 100644
index 726e17b883d..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
+++ /dev/null
@@ -1,356 +0,0 @@
-<?php
-/**
- * Integration tests for the batch RenewalDispatcher.
- *
- * @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\Api\Contracts;
-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\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher
- */
-class RenewalDispatcherTest extends EngineIntegrationTestCase {
-
- /**
- * A gateway that always approves the scheduled charge inline (the dummy-gateway shape).
- */
- private const GATEWAY_APPROVING = 'engine_dispatch_gateway_approve';
-
- /**
- * 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';
-
- public function set_up(): void {
- parent::set_up();
- GatewayCapabilities::reset();
- ConsumerRegistry::reset();
- }
-
- public function tear_down(): void {
- // Clear any recurring scan action a scheduling test enqueued so it cannot leak between tests.
- as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
- ConsumerRegistry::reset();
- GatewayCapabilities::reset();
- parent::tear_down();
- }
-
- /**
- * The scan moment used by the tests (cycle 1 ends 2026-02-15, so a renewal is due then).
- */
- private function scan_now(): DateTimeImmutable {
- return new DateTimeImmutable( '2026-02-15 00:00:00', new DateTimeZone( 'UTC' ) );
- }
-
- /**
- * Sign up a contract through the contracts facade so its billing chain holds cycle 1 (billed),
- * with its next payment due at the given date.
- *
- * @param string $gateway Gateway id stamped on the order/contract.
- * @param string $next_payment_gmt The contract's next-payment date.
- * @return Contract The persisted contract with cycle 1 billed.
- */
- private function sign_up_contract( string $gateway, string $next_payment_gmt ): Contract {
- $plan = $this->plan_view( $this->make_plan() );
-
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( $gateway );
- $order->set_total( '19.99' );
- $order->set_date_paid( '2026-01-15 00:00:00' );
- $order->save();
-
- $id = $this->sign_up_from_order( $order, $plan );
-
- // Sign-up anchors the first renewal off the plan cadence; pin the schedule date
- // the test reasons about so due/not-due is explicit.
- Contracts::update( $id, array( 'next_payment_gmt' => $next_payment_gmt ) );
-
- $contract = ( new ContractRepository() )->find( $id );
- $this->assertInstanceOf( Contract::class, $contract );
-
- return $contract;
- }
-
- /**
- * @testdox run skips the whole scan and charges nothing when no consumer is registered.
- */
- public function test_run_is_gated_when_no_consumer_is_registered(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-15 00:00:00' );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // No consumer registered: the gate is closed.
- $this->assertTrue( ConsumerRegistry::is_empty() );
-
- $processed = ( new RenewalDispatcher() )->run_batch( $this->scan_now() );
-
- $this->assertSame( 0, $processed, 'A gated run processes no contracts.' );
-
- // The contract was not advanced: still cycle 1, schedule unmoved.
- $repo = new ContractRepository();
- $this->assertSame( 1, $this->head_count( $contract_id ) );
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox run renews a due contract once a consumer is registered: cycle 2 billed, schedule advanced.
- */
- public function test_run_renews_a_due_contract_when_a_consumer_is_registered(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- ConsumerRegistry::register( self::CONSUMER );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-15 00:00:00' );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $processed = ( new RenewalDispatcher() )->run_batch( $this->scan_now() );
- $this->assertSame( 1, $processed );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
-
- // 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( new CycleStatus( CycleStatus::BILLED ) ) );
-
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- $this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
-
- // A renewal order was created for cycle 2.
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- }
-
- /**
- * @testdox run leaves a not-yet-due contract untouched.
- */
- public function test_run_leaves_a_not_yet_due_contract_untouched(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- ConsumerRegistry::register( self::CONSUMER );
-
- // Due far in the future, after the scan moment.
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-06-15 00:00:00' );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $processed = ( new RenewalDispatcher() )->run_batch( $this->scan_now() );
- $this->assertSame( 0, $processed );
-
- // Untouched: still cycle 1, schedule unmoved, no renewal order.
- $repo = new ContractRepository();
- $this->assertSame( 1, $this->head_count( $contract_id ) );
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-06-15 00:00:00', $reloaded->get_next_payment_gmt() );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- }
-
- /**
- * @testdox run honours the batch limit, renewing at most a batch of due contracts per tick.
- */
- public function test_run_honours_the_batch_limit(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- ConsumerRegistry::register( self::CONSUMER );
-
- // Three due contracts, but a batch size of two: the third drains on the next tick.
- $first = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-01-20 00:00:00' );
- $second = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-01-25 00:00:00' );
- $third = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-01 00:00:00' );
-
- $repo = new ContractRepository();
- $dispatcher = new RenewalDispatcher( $repo, null );
-
- $processed = $dispatcher->run_batch( $this->scan_now(), 2 );
- $this->assertSame( 2, $processed, 'A single tick processes at most the batch size.' );
-
- // The two oldest-due contracts advanced; the third is still at cycle 1.
- $this->assertSame( 2, $this->head_count( (int) $first->get_id() ) );
- $this->assertSame( 2, $this->head_count( (int) $second->get_id() ) );
- $this->assertSame( 1, $this->head_count( (int) $third->get_id() ) );
-
- // The next tick drains the remaining due contract.
- $processed_next = $dispatcher->run_batch( $this->scan_now(), 2 );
- $this->assertSame( 1, $processed_next );
- $this->assertSame( 2, $this->head_count( (int) $third->get_id() ) );
- }
-
- /**
- * @testdox ensure_scheduled enqueues exactly one recurring scan action and is idempotent.
- */
- public function test_ensure_scheduled_is_idempotent(): void {
- ConsumerRegistry::register( self::CONSUMER );
-
- // The engine schedules the recurring action (and sets its re-check option) once at
- // bootstrap, committed before the per-test transaction. Reset both so this test starts
- // from a clean, unscheduled slate; the rollback restores the bootstrap state afterwards.
- as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
- delete_option( 'woocommerce_subscriptions_engine_dispatch_scheduled_check' );
- $this->assertFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ), 'No recurring action after the reset.' );
-
- RenewalDispatcher::ensure_scheduled();
- $this->assertNotFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ) );
-
- // A second call must not enqueue a duplicate.
- RenewalDispatcher::ensure_scheduled();
-
- $pending = as_get_scheduled_actions(
- array(
- 'hook' => RenewalDispatcher::HOOK,
- 'group' => RenewalDispatcher::GROUP,
- 'status' => 'pending',
- ),
- 'ids'
- );
- $this->assertCount( 1, $pending, 'Exactly one recurring scan action is enqueued.' );
- }
-
- /**
- * @testdox ensure_scheduled enqueues nothing while no consumer is registered.
- *
- * A store without a consumer extension runs no renewals, so it carries no recurring
- * scan action either - the scheduling is gated exactly like the run.
- */
- public function test_ensure_scheduled_skips_without_a_consumer(): void {
- as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
- delete_option( 'woocommerce_subscriptions_engine_dispatch_scheduled_check' );
-
- RenewalDispatcher::ensure_scheduled();
-
- $this->assertFalse(
- as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ),
- 'No recurring action is enqueued while the consumer registry is empty.'
- );
- }
-
- /**
- * @testdox ensure_scheduled removes the recurring scan once every consumer is gone.
- */
- public function test_ensure_scheduled_removes_the_job_when_consumers_are_gone(): void {
- as_unschedule_all_actions( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP );
- delete_option( 'woocommerce_subscriptions_engine_dispatch_scheduled_check' );
-
- ConsumerRegistry::register( self::CONSUMER );
- RenewalDispatcher::ensure_scheduled();
- $this->assertNotFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ) );
-
- // Every consumer deactivates: the next gated boot removes the recurring scan.
- ConsumerRegistry::reset();
- RenewalDispatcher::ensure_scheduled();
- $this->assertFalse(
- as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ),
- 'The recurring scan does not keep ticking after the last consumer deactivates.'
- );
-
- // A returning consumer schedules promptly again.
- ConsumerRegistry::register( self::CONSUMER );
- RenewalDispatcher::ensure_scheduled();
- $this->assertNotFalse( as_next_scheduled_action( RenewalDispatcher::HOOK, array(), RenewalDispatcher::GROUP ) );
- }
-
- /**
- * @testdox run_batch is a no-op for a non-positive limit.
- */
- public function test_run_batch_returns_zero_for_a_non_positive_limit(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- ConsumerRegistry::register( self::CONSUMER );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-01-20 00:00:00' );
-
- $dispatcher = new RenewalDispatcher();
- $this->assertSame( 0, $dispatcher->run_batch( $this->scan_now(), 0 ) );
- $this->assertSame( 0, $dispatcher->run_batch( $this->scan_now(), -5 ) );
-
- // Nothing was renewed by the no-op ticks.
- $this->assertSame( 1, $this->head_count( (int) $contract->get_id() ) );
- }
-
- /**
- * @testdox handle_tick routes the Action Scheduler dispatch through run(), advancing a due renewal.
- */
- public function test_handle_tick_routes_through_run(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- ConsumerRegistry::register( self::CONSUMER );
-
- // Due in the past relative to the real dispatch moment, so the tick picks it up.
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING, '2026-02-15 00:00:00' );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Drive the Action Scheduler entry point (uses the real "now").
- ( new RenewalDispatcher() )->handle_tick();
-
- // The dispatch reached the money-path: cycle 2 billed.
- $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( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
- /**
- * Renewal orders tagged for a contract at a given chargeable number, narrowed in PHP.
- *
- * @param int $contract_id Contract id.
- * @param int $count Chargeable number.
- * @return array<int, WC_Order>
- */
- private function renewal_orders_for_cycle( int $contract_id, int $count ): array {
- $orders = wc_get_orders(
- array(
- 'limit' => -1,
- 'type' => 'shop_order',
- 'status' => 'any',
- '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
- )
- );
-
- return array_values(
- array_filter(
- is_array( $orders ) ? $orders : array(),
- static function ( $order ) use ( $count ) {
- return $order instanceof WC_Order
- && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
- && (string) $count === $order->get_meta( '_subscription_renewal_cycle' );
- }
- )
- );
- }
-
- /**
- * The billing chain head's count, or null for an empty chain.
- *
- * @param int $contract_id Contract id.
- */
- private function head_count( int $contract_id ): ?int {
- $head = ( new ContractRepository() )->find_chain_head( $contract_id );
-
- return null === $head ? null : $head->get_count();
- }
-}
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
deleted file mode 100644
index fa156bf7d06..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
+++ /dev/null
@@ -1,2035 +0,0 @@
-<?php
-/**
- * Integration tests for RenewalEngine.
- *
- * @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\Api\Contracts;
-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\Api\Plans;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
-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;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
- */
-class RenewalEngineTest extends EngineIntegrationTestCase {
-
- private const GATEWAY = 'engine_test_gateway';
-
- /**
- * A gateway that always approves the scheduled charge, marking the renewal order
- * paid inline (the dummy-gateway shape: `payment_complete()` within the action).
- */
- private const GATEWAY_APPROVING = 'engine_test_gateway_approve';
-
- /**
- * A gateway that declares `recurring` and reports a hard decline synchronously,
- * moving the renewal order to `failed` (the failed-charge path).
- */
- private const GATEWAY_DECLINING = 'engine_test_gateway_decline';
-
- /**
- * A gateway that declares `recurring` but registers no handler, so the renewal order
- * stays `pending` (uncharged) - the async-pending shape: neither paid nor failed, which
- * the money-path parks in `processing` awaiting a later confirmation.
- */
- private const GATEWAY_PENDING = 'engine_test_gateway_pending';
-
- public function set_up(): void {
- parent::set_up();
- GatewayCapabilities::reset();
- ConsumerRegistry::reset();
- }
-
- public function tear_down(): void {
- ConsumerRegistry::reset();
- GatewayCapabilities::reset();
- parent::tear_down();
- }
-
- /**
- * Drive one scheduled scan tick over the batch dispatcher - the production path for
- * scheduled renewals - and report what it did to `$contract_id`: the head cycle's
- * renewal order when the tick touched the contract's chain, or null when it did not
- * (not due, excluded by the scan, or nothing changed).
- *
- * @param int $contract_id The contract under test.
- * @param \DateTimeImmutable|null $now The scan moment; defaults to now (UTC).
- */
- private function run_scheduled_renewal( int $contract_id, ?\DateTimeImmutable $now = null ): ?WC_Order {
- ConsumerRegistry::register( 'engine-tests' );
-
- $repo = new ContractRepository();
- $before = $repo->find_chain_head( $contract_id );
-
- ( new RenewalDispatcher() )->run_batch( $now, 50 );
-
- $after = $repo->find_chain_head( $contract_id );
- if ( null === $after ) {
- return null;
- }
-
- $untouched = null !== $before
- && $before->get_sequence_no() === $after->get_sequence_no()
- && $before->get_status()->get_value() === $after->get_status()->get_value()
- && $before->get_order_id() === $after->get_order_id()
- && $before->get_claimed_until_gmt() === $after->get_claimed_until_gmt();
-
- if ( $untouched ) {
- return null;
- }
-
- $order_id = $after->get_order_id();
- $order = null === $order_id ? false : wc_get_order( $order_id );
-
- return $order instanceof WC_Order ? $order : null;
- }
-
- /**
- * Create a monthly plan and return its view (the sign-up helper needs the plan).
- *
- * @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
- */
- private function make_plan_view( ?int $max_cycles = null ): PlanView {
- return $this->plan_view(
- $this->make_plan(
- array(
- 'billing_policy' => array(
- 'period' => 'month',
- 'interval' => 1,
- 'max_cycles' => $max_cycles,
- ),
- )
- )
- );
- }
-
- /**
- * Sign up a contract through the contracts facade so its billing chain holds cycle 1
- * (billed), the starting point the renewal advances from.
- *
- * @param string $gateway Gateway id stamped on the order/contract.
- * @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
- * @return Contract The persisted contract with cycle 1 billed.
- */
- private function sign_up_contract( string $gateway, ?int $max_cycles = null ): Contract {
- $plan = $this->make_plan_view( $max_cycles );
-
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( $gateway );
- $order->set_total( '19.99' );
- $order->set_date_paid( '2026-01-15 00:00:00' );
- $order->save();
-
- return $this->reload_contract( $this->sign_up_from_order( $order, $plan ) );
- }
-
- 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;
- }
-
- private function make_contract( int $plan_id, int $origin_order_id ): Contract {
- // A lean contract row (no cycles). The renewal-advancement tests that
- // exercise the money-path are skipped until the dispatcher slice; when they
- // are reactivated they will append a billing cycle (with an expected_total)
- // so the renewal amount resolves off the current cycle.
- $contract = Contract::create(
- array(
- 'status' => ContractStatus::ACTIVE,
- 'customer_id' => 1,
- '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',
- )
- );
- ( new ContractRepository() )->insert( $contract );
-
- return $contract;
- }
-
- /**
- * @testdox the scheduled scan creates a renewal order tagged for the next chargeable number.
- */
- public function test_scheduled_renewal_creates_renewal_order(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
-
- // The renewal order is created and tagged with the renewal relation + chargeable number.
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertSame( (string) $contract_id, $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
- $this->assertSame( OrderLinkage::RELATION_RENEWAL, $renewal_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
-
- // The chain holds cycle 1 (from signup), so the renewal targets the next number, 2.
- $this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- }
-
- /**
- * @testdox the scheduled scan does not re-select a failed cycle (no second order).
- *
- * A declined charge settles cycle 2 `failed`. A `failed` head is not selectable (dunning is
- * deferred), so a retried run is a no-op - no second cycle, no second order for the number.
- */
- public function test_scheduled_renewal_does_not_reselect_a_failed_cycle(): void {
- $this->fail_charges_for( self::GATEWAY_DECLINING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_DECLINING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $first = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $first );
-
- // Cycle 2 settled failed; a retried run does not re-select it (no duplicate order).
- $retry = $this->run_scheduled_renewal( $contract_id );
- $this->assertNull( $retry );
-
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- }
-
- /**
- * @testdox the scheduled scan skips when the head cycle is a live pending claim.
- *
- * A pending cycle for the target count already exists with no lease recorded (a live claim,
- * not a crashed one). Selection targets that same count, and the money-path's reclaim step
- * finds a non-reclaimable pending head, so it returns null and creates no duplicate cycle or
- * order.
- */
- public function test_scheduled_renewal_skips_when_the_cycle_is_already_claimed(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Pre-claim cycle 2 pending directly (no tagged renewal order), so the order-meta
- // pre-check does not fire and the claim collides on the UNIQUE index instead.
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
- $claimed = Cycle::create(
- array(
- 'contract_id' => $contract_id,
- 'sequence_no' => $previous->get_sequence_no() + 1,
- 'count' => 2,
- 'status' => new CycleStatus( CycleStatus::PENDING ),
- 'starts_at_gmt' => '2026-02-15 00:00:00',
- 'ends_at_gmt' => '2026-03-15 00:00:00',
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- )
- );
- $repo->append_cycle( $claimed, $previous );
-
- $result = $this->run_scheduled_renewal( $contract_id );
- $this->assertNull( $result );
-
- // No renewal order was created for count 2, and only the one pre-claimed cycle exists.
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- $at_count_2 = array_filter(
- $repo->find_cycle_history( $contract_id ),
- static function ( Cycle $cycle ): bool {
- return 2 === $cycle->get_count();
- }
- );
- $this->assertCount( 1, $at_count_2 );
- }
-
- /**
- * @testdox the scheduled scan reclaims a stalled pending cycle whose crash-recovery lease has expired.
- *
- * A charge that claimed cycle 2 pending then crashed before settling (no renewal order
- * tagged) leaves a stuck pending cycle. Once its `claimed_until` lease has expired, a
- * later run reclaims that same cycle (re-stamping the lease), charges it, and settles it
- * billed - no duplicate cycle is created.
- */
- public function test_scheduled_renewal_reclaims_a_stalled_cycle_with_an_expired_lease(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Seed a stalled pending cycle 2 whose lease expired a minute before the run (no tagged order).
- $now = new \DateTimeImmutable( '2026-02-15 00:05:00', new \DateTimeZone( 'UTC' ) );
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
- $stalled = $this->make_pending_cycle_2( $contract_id, $previous, $now->modify( '-60 seconds' )->format( 'Y-m-d H:i:s' ) );
- $stalled_id = $stalled->get_id();
- $this->assertNotNull( $stalled_id );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id, $now );
-
- // The reclaimed cycle is billed and resolved through a renewal order.
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertTrue( $renewal_order->is_paid() );
- $this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- // 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( 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
- // recomputed next period (2026-04-15) - a reclaim must not skip a billing cycle.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- $this->assertNotNull( $reloaded->get_last_payment_gmt() );
-
- // Exactly one billing cycle for count 2 (no duplicate claimed).
- $at_count_2 = array_filter(
- $repo->find_cycle_history( $contract_id ),
- static function ( Cycle $cycle ): bool {
- return 2 === $cycle->get_count();
- }
- );
- $this->assertCount( 1, $at_count_2 );
- }
-
- /**
- * @testdox the scheduled scan leaves a pending cycle alone while its crash-recovery lease is live.
- *
- * A pending cycle 2 with a still-live `claimed_until` lease is an active claim (a
- * concurrent worker), so a second run skips it: no charge, no duplicate, and the live
- * lease is left untouched.
- */
- public function test_scheduled_renewal_leaves_a_live_leased_cycle_alone(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Seed a pending cycle 2 whose lease is still an hour in the future (a live claim).
- $now = new \DateTimeImmutable( '2026-02-15 00:05:00', new \DateTimeZone( 'UTC' ) );
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
- $live_until = $now->modify( '+1 hour' )->format( 'Y-m-d H:i:s' );
- $claimed = $this->make_pending_cycle_2( $contract_id, $previous, $live_until );
-
- $result = $this->run_scheduled_renewal( $contract_id, $now );
- $this->assertNull( $result );
-
- // No renewal order for count 2, and the seeded cycle is untouched (still pending, lease intact).
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- $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( new CycleStatus( CycleStatus::PENDING ) ) );
- $this->assertSame( $live_until, $head->get_claimed_until_gmt() );
- }
-
- /**
- * @testdox the scheduled scan advances the chain: cycle 2 billed, order linked, schedule moved.
- */
- public function test_scheduled_renewal_advances_the_chain_on_a_successful_charge(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
-
- // The renewal order bills the new cycle's expected_total (carried forward from
- // cycle 1's recurring amount) and is paid by the approving gateway.
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertTrue( $renewal_order->is_paid() );
- $this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
-
- // Cycle 2 exists, billed, count 2, linked to the renewal order, refs carried forward.
- $this->assertInstanceOf( Cycle::class, $cycle );
- $this->assertSame( 2, $cycle->get_sequence_no() );
- $this->assertSame( 2, $cycle->get_count() );
- $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() );
-
- // The contract schedule advanced one cadence; last_payment recorded.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- $this->assertNotNull( $reloaded->get_last_payment_gmt() );
- $this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
- }
-
- /**
- * @testdox the scheduled scan builds the renewal order from the contract's own line items and addresses.
- */
- public function test_scheduled_renewal_builds_renewal_from_contract_items_and_addresses(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $product = new \WC_Product_Simple();
- $product->set_name( 'Monthly Filters' );
- $product->set_regular_price( '19.99' );
- $product_id = (int) $product->save();
-
- // Sign up from an order carrying a real recurring line item + addresses, so the contract
- // stores them and the renewal builder has something other than the origin order to read.
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( self::GATEWAY_APPROVING );
- $order->set_total( '39.98' );
- $order->set_date_paid( '2026-01-15 00:00:00' );
- $order->set_billing_address(
- array(
- 'first_name' => 'Ada',
- 'last_name' => 'Lovelace',
- 'country' => 'US',
- 'email' => 'ada@example.test',
- )
- );
- $order->set_shipping_address(
- array(
- 'first_name' => 'Ada',
- 'last_name' => 'Lovelace',
- 'country' => 'US',
- )
- );
- $line = new \WC_Order_Item_Product();
- $line->set_name( 'Monthly Filters' );
- $line->set_product_id( $product_id );
- $line->set_quantity( 2 );
- $line->set_subtotal( '39.98' );
- $line->set_total( '39.98' );
- $order->add_item( $line );
- $order->save();
-
- $contract_id = $this->sign_up_from_order( $order, $this->make_plan_view() );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
-
- // Exactly the contract's recurring line item, carried from the contract.
- $items = array_values( $renewal_order->get_items() );
- $this->assertCount( 1, $items );
- $item = $items[0];
- $this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
- $this->assertSame( $product_id, $item->get_product_id() );
- $this->assertSame( 'Monthly Filters', $item->get_name() );
- $this->assertSame( 2, $item->get_quantity() );
-
- // Addresses are taken from the contract, not re-read off the origin order.
- $this->assertSame( 'Ada', $renewal_order->get_billing_first_name() );
- $this->assertSame( 'US', $renewal_order->get_billing_country() );
- $this->assertSame( 'US', $renewal_order->get_shipping_country() );
- }
-
- /**
- * @testdox the scheduled scan on a declined charge marks the cycle failed and leaves the schedule.
- */
- public function test_scheduled_renewal_marks_the_cycle_failed_when_the_gateway_declines(): void {
- // The gateway reports a hard decline synchronously: the renewal order moves to failed.
- $this->fail_charges_for( self::GATEWAY_DECLINING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_DECLINING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertFalse( $renewal_order->is_paid() );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
-
- // Cycle 2 exists and failed, but the renewal order is recorded on it even though the
- // 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( new CycleStatus( CycleStatus::FAILED ) ) );
- $this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );
-
- // The contract schedule is untouched (left for dunning), still active.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
- $this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
-
- // Failure bookkeeping: the attempt is recorded, but not a successful payment.
- $this->assertNull( $reloaded->get_last_payment_gmt() );
- $this->assertNotNull( $reloaded->get_last_attempt_gmt() );
- }
-
- /**
- * @testdox the scheduled scan retry of a failed cycle adds no duplicate cycle/order.
- *
- * A declined charge leaves cycle 2 `failed` with its order (and the schedule unchanged).
- * A `failed` head is not selectable, so re-firing is an idempotent no-op: no second order,
- * no second cycle for count 2. (Forward advancement after a SUCCESSFUL bill is a distinct
- * renewal, not a retry.)
- */
- public function test_scheduled_renewal_retry_of_a_failed_cycle_is_idempotent(): void {
- $this->fail_charges_for( self::GATEWAY_DECLINING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_DECLINING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $first = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $first );
- $this->assertFalse( $first->is_paid() );
-
- // A retry while cycle 2 is failed creates no duplicate.
- $second = $this->run_scheduled_renewal( $contract_id );
- $this->assertNull( $second );
-
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- // Exactly one billing cycle for count 2.
- $history = ( new ContractRepository() )->find_cycle_history( $contract_id );
- $at_count_2 = array_filter(
- $history,
- static function ( Cycle $cycle ): bool {
- return 2 === $cycle->get_count();
- }
- );
- $this->assertCount( 1, $at_count_2 );
- }
-
- /**
- * @testdox the scheduled scan falls back to the live plan cadence when the cycle carries no snapshot.
- *
- * After cycle 1 is billed (terminal) find_chain_head() hydrates it WITHOUT its snapshot
- * value objects, so resolve_plan_snapshot() rebuilds the cadence from the live selling plan.
- * The renewal advances normally on that fallback.
- */
- public function test_scheduled_renewal_falls_back_to_live_plan_when_cycle_has_no_snapshot(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Precondition: the billed head cycle carries no in-memory plan snapshot, so the
- // money-path must use the live-plan fallback to know the cadence.
- $repo = new ContractRepository();
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertNull( $head->get_plan_snapshot() );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
-
- // Advanced one monthly cadence from the live plan (cycle 1 ended 2026-02-15).
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
-
- $cycle = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $cycle );
- $this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
- /**
- * @testdox the scheduled scan never surfaces a contract with no billing chain.
- *
- * Checkout always creates cycle 1, so a chainless (lean / manual) contract is a case the
- * engine does not renew. The cycle-aware scan excludes it in SQL (no head cycle to join),
- * so a tick claims nothing and bills nothing for it and its schedule is left untouched -
- * the exclusion, not a park, is what keeps it out of the batch budget.
- */
- public function test_scheduled_renewal_ignores_a_contract_with_no_billing_chain(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
- // A lean contract is persisted with no cycle chain (but with a due date).
- $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 );
-
- $result = $this->run_scheduled_renewal( $contract_id );
- $this->assertNull( $result );
-
- // Nothing was claimed: no cycle, no renewal order.
- $repo = new ContractRepository();
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
- $this->assertNull( $repo->find_chain_head( $contract_id ) );
-
- // Not parked - never selected: the schedule stays as it was.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt(), 'The scan exclusion leaves the schedule untouched.' );
- }
-
- /**
- * @testdox the scheduled scan parks a contract whose plan resolves to nothing (no snapshot, deleted live plan).
- *
- * When neither the contract's plan snapshot nor the live selling plan resolves, the renewal
- * cannot bill. The dispatcher catches the pre-flight impossibility and parks the contract
- * (clears the schedule) so it leaves the due set instead of holding the front of the scan forever.
- */
- public function test_scheduled_renewal_parks_a_contract_whose_plan_is_unresolvable(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Give it a billing chain (so it passes the chainless guard) but no plan snapshot...
- $repo = new ContractRepository();
- $repo->append_cycle(
- Cycle::create(
- array(
- 'contract_id' => $contract_id,
- 'sequence_no' => 1,
- 'count' => 1,
- 'status' => new CycleStatus( CycleStatus::BILLED ),
- 'starts_at_gmt' => '2026-01-15 00:00:00',
- 'ends_at_gmt' => '2026-02-15 00:00:00',
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- )
- )
- );
-
- // ...then delete the live plan, so neither the snapshot nor the live plan resolves.
- ( new PlanRepository() )->delete( $plan_id );
-
- $result = $this->run_scheduled_renewal( $contract_id );
- $this->assertNull( $result );
-
- // No cycle 2 claimed, and the contract is parked out of the due set.
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertNull( $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox the scheduled scan parks a contract without a snapshot whose live plan billing payload is null, does not parse, or has no usable cadence.
- *
- * @dataProvider provide_unusable_live_billing_payloads
- *
- * @param array<string, mixed>|null $billing The live plan's billing payload.
- */
- public function test_scheduled_renewal_parks_a_contract_whose_live_billing_is_unusable( ?array $billing ): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $repo->append_cycle(
- Cycle::create(
- array(
- 'contract_id' => $contract_id,
- 'sequence_no' => 1,
- 'count' => 1,
- 'status' => new CycleStatus( CycleStatus::BILLED ),
- 'starts_at_gmt' => '2026-01-15 00:00:00',
- 'ends_at_gmt' => '2026-02-15 00:00:00',
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- )
- )
- );
-
- $this->assertInstanceOf(
- PlanView::class,
- Plans::update(
- $plan_id,
- array(
- 'extension_slug' => self::PLAN_OWNER,
- 'billing_policy' => $billing,
- )
- )
- );
-
- $result = null;
- $warnings = $this->capture_engine_log(
- 'warning',
- array(
- 'contract_id' => $contract_id,
- 'plan_id' => $plan_id,
- ),
- function () use ( &$result, $contract_id ): void {
- $result = $this->run_scheduled_renewal( $contract_id );
- }
- );
-
- $this->assertNull( $result );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertNull( $reloaded->get_next_payment_gmt(), 'The contract is parked out of the due set.' );
- $this->assertNotEmpty( $warnings, 'A null or unusable live billing payload is logged with the contract and plan.' );
- }
-
- /**
- * @return array<string, array{0: array<string, mixed>|null}>
- */
- public function provide_unusable_live_billing_payloads(): array {
- return array(
- 'null payload' => array( null ),
- 'missing interval' => array( array( 'period' => 'month' ) ),
- 'unknown period' => array(
- array(
- 'period' => 'decade',
- 'interval' => 1,
- ),
- ),
- 'zero interval' => array(
- array(
- 'period' => 'month',
- 'interval' => 0,
- ),
- ),
- );
- }
-
- /**
- * @testdox the scheduled scan falls back to the live plan when the snapshot billing has no usable cadence, and parks when neither is usable.
- *
- * @dataProvider provide_unusable_snapshot_billing_payloads
- *
- * @param array<string, mixed> $snapshot_billing The snapshot's billing payload.
- * @param array<string, mixed>|null $live_billing The live plan's billing payload.
- * @param string|null $expected_next The next payment after the run; null when parked.
- */
- public function test_scheduled_renewal_reads_an_unusable_snapshot_billing_through_the_live_plan( array $snapshot_billing, ?array $live_billing, ?string $expected_next ): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $plan = $this->make_plan_view();
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( self::GATEWAY_APPROVING );
- $order->set_total( '19.99' );
- $order->set_date_paid( '2026-01-15 00:00:00' );
- $order->save();
-
- $contract_id = $this->sign_up_from_order( $order, $plan );
- $this->seed_plan_snapshot(
- $contract_id,
- array(
- 'selling_plan_id' => $plan->get_id(),
- 'name' => $plan->get_name(),
- 'billing_policy' => $snapshot_billing,
- )
- );
- $this->assertInstanceOf(
- PlanView::class,
- Plans::update(
- $plan->get_id(),
- array(
- 'extension_slug' => self::PLAN_OWNER,
- 'billing_policy' => $live_billing,
- )
- )
- );
-
- $result = null;
- $warnings = $this->capture_engine_log(
- 'warning',
- array( 'contract_id' => $contract_id ),
- function () use ( &$result, $contract_id ): void {
- $result = $this->run_scheduled_renewal( $contract_id );
- }
- );
-
- $this->assertNotEmpty( $warnings, 'An unusable snapshot billing payload is logged.' );
- $this->assertSame( $expected_next, $this->reload_contract( $contract_id )->get_next_payment_gmt() );
- if ( null === $expected_next ) {
- $this->assertNull( $result );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ), 'A parked contract bills nothing.' );
- } else {
- $this->assertInstanceOf( WC_Order::class, $result, 'The renewal bills on the live cadence.' );
- }
- }
-
- /**
- * @return array<string, array{0: array<string, mixed>, 1: array<string, mixed>|null, 2: string|null}>
- */
- public function provide_unusable_snapshot_billing_payloads(): array {
- $monthly = array(
- 'period' => 'month',
- 'interval' => 1,
- );
- $decade = array(
- 'period' => 'decade',
- 'interval' => 1,
- );
- $zero = array(
- 'period' => 'month',
- 'interval' => 0,
- );
-
- return array(
- 'unknown period, live monthly' => array( $decade, $monthly, '2026-03-15 00:00:00' ),
- 'zero interval, live monthly' => array( $zero, $monthly, '2026-03-15 00:00:00' ),
- 'unknown period, live unusable' => array( $decade, $zero, null ),
- 'zero interval, live null' => array( $zero, null, null ),
- );
- }
-
- /**
- * @testdox the scheduled scan resumes a stalled renewal whose order was saved but never charged.
- *
- * A run that claimed cycle 2 pending and saved its renewal order, then crashed before the
- * charge, leaves a stalled pending cycle (expired lease) plus an unpaid, un-tokenised order.
- * The idempotency pre-check would once have skipped forever on the existing order; instead the
- * run must resume that SAME order - charge it, settle the cycle billed, advance the schedule.
- */
- public function test_scheduled_renewal_resumes_a_stalled_renewal_with_an_unpaid_order(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
-
- // A crashed attempt: cycle 2 pending with an expired lease, plus its saved-but-uncharged order.
- $stalled = $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
- $stalled_id = $stalled->get_id();
- $this->assertNotNull( $stalled_id );
- $ghost = $this->make_ghost_renewal_order( $contract_id, 2, false );
-
- $resumed = $this->run_scheduled_renewal( $contract_id );
-
- // The SAME order is resumed and charged - no second order for the number.
- $this->assertInstanceOf( WC_Order::class, $resumed );
- $this->assertSame( $ghost->get_id(), $resumed->get_id() );
- $paid_order = wc_get_order( $ghost->get_id() );
- $this->assertInstanceOf( WC_Order::class, $paid_order );
- $this->assertTrue( $paid_order->is_paid() );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- // The stalled cycle is reclaimed (same row) and settled billed, lease cleared.
- $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( 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.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox the scheduled scan settles a stalled renewal whose order was already paid, without re-charging.
- *
- * A crash AFTER the gateway was paid but before the cycle settled leaves a paid renewal order
- * on a stalled pending cycle. Recovery must settle it from that paid state and never fire a
- * second charge, which could double-charge the customer.
- */
- public function test_scheduled_renewal_settles_a_stalled_renewal_with_a_paid_order_without_recharging(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
-
- $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
- $ghost = $this->make_ghost_renewal_order( $contract_id, 2, true );
-
- // Spy on the charge hook (before the approving handler): an already-paid order must not be charged.
- $charge_attempts = 0;
- add_action(
- 'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_APPROVING,
- static function ( $amount, $order ) use ( &$charge_attempts ): void {
- unset( $amount, $order );
- ++$charge_attempts;
- },
- 5,
- 2
- );
-
- $resumed = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $resumed );
- $this->assertSame( $ghost->get_id(), $resumed->get_id() );
- $this->assertSame( 0, $charge_attempts, 'An already-paid order must not be charged again.' );
-
- // The cycle is settled billed from the paid state and the schedule advances one cadence.
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertSame( 2, $head->get_count() );
- $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
-
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox the scheduled scan expires the contract when it hits max cycles.
- */
- public function test_scheduled_renewal_expires_contract_at_max_cycles(): void {
- $this->markTestSkipped( 'Max-cycle expiry lands with the dispatcher.' );
- }
-
- /**
- * @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_due_scan_excludes_a_non_active_contract_and_leaves_it_untouched(): 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 );
-
- $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, 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 ) );
- }
-
- /**
- * Store a plan snapshot row and point the contract at it.
- *
- * @param int $contract_id Stored contract id.
- * @param array<string, mixed> $payload Plan snapshot payload.
- */
- private function seed_plan_snapshot( int $contract_id, array $payload ): void {
- $contract = $this->reload_contract( $contract_id );
- $snapshot = PlanSnapshot::from_array( $payload );
- $contract->set_plan_snapshot_id(
- ( new SnapshotStore() )->insert( $contract_id, SnapshotStore::TYPE_PLAN, $snapshot->get_selling_plan_id(), $snapshot->to_payload(), $snapshot->get_schema_version() )
- );
- ( new ContractRepository() )->update_fields( $contract, array( 'plan_snapshot_id' ) );
- }
-
- /**
- * Reload a contract, asserting it still exists.
- *
- * @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;
- }
-
- /**
- * @testdox the scheduled scan skips a gateway-scheduled contract and creates no renewal order.
- */
- public function test_scheduled_renewal_skips_gateway_scheduled_contract(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = Contract::create(
- array(
- 'extension_slug' => 'engine-tests',
- 'status' => ContractStatus::ACTIVE,
- 'customer_id' => 1,
- 'currency' => 'USD',
- 'selling_plan_id' => $plan_id,
- 'origin_order_id' => $order->get_id(),
- 'payment_method' => self::GATEWAY,
- 'start_gmt' => '2026-01-15 00:00:00',
- 'next_payment_gmt' => '2026-02-15 00:00:00',
- 'schedule_source' => Contract::SCHEDULE_SOURCE_GATEWAY,
- )
- );
- $contract_id = ( new ContractRepository() )->insert( $contract );
-
- // Active, but the gateway owns the schedule: the primitive path bails.
- $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
- }
-
- /**
- * @testdox the scheduled scan skips an unknown contract.
- */
- public function test_scheduled_renewal_skips_unknown_contract(): void {
- $this->assertNull( $this->run_scheduled_renewal( 999999 ) );
- }
-
- /**
- * @testdox the scheduled scan leaves the cycle processing when the charge is neither paid nor failed.
- *
- * A gateway that accepts the charge but confirms later (async) leaves the renewal order
- * neither paid nor failed. The money-path settles the cycle `processing` - awaiting the
- * gateway - without advancing the schedule or treating it as a failure. A `processing` head
- * is not re-selected, so a later run is a no-op until the order settles.
- */
- public function test_scheduled_renewal_leaves_the_cycle_processing_when_the_charge_is_pending(): void {
- // Declared recurring, but no handler settles the order: it stays pending (async-accepted shape).
- GatewayCapabilities::declare( self::GATEWAY_PENDING, array( GatewayCapabilities::RECURRING ) );
-
- $contract = $this->sign_up_contract( self::GATEWAY_PENDING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $engine = new RenewalEngine();
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertFalse( $renewal_order->is_paid() );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $cycle );
- $this->assertSame( 2, $cycle->get_count() );
- $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() );
-
- // The schedule is not advanced (the charge has not confirmed).
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
-
- // A processing head is not re-selected: a later run is a no-op, no duplicate order.
- $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- }
-
- /**
- * @testdox complete_from_order bills a processing cycle once its order is paid, and is idempotent.
- *
- * The async confirmation path: a cycle left `processing` is settled `billed` (and the schedule
- * advanced) when its order becomes paid and completion is driven from that order - the same
- * routine the order-status listener runs. Re-driving it is a no-op.
- */
- public function test_complete_from_order_bills_a_processing_cycle_when_paid(): void {
- GatewayCapabilities::declare( self::GATEWAY_PENDING, array( GatewayCapabilities::RECURRING ) );
-
- $contract = $this->sign_up_contract( self::GATEWAY_PENDING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $engine = new RenewalEngine();
- $order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $order );
-
- $repo = new ContractRepository();
- $processing_head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $processing_head );
- $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();
- $paid_order = wc_get_order( $order->get_id() );
- $this->assertInstanceOf( WC_Order::class, $paid_order );
- $engine->complete_from_order( $paid_order );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
-
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
-
- // Idempotent: re-driving completion on the already-billed cycle changes nothing.
- $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( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
- /**
- * @testdox the scheduled scan does not charge the next cycle ahead of its period (the selection due-guard).
- *
- * After cycle 2 is billed its period runs to 2026-03-15. A scheduled scan tick at
- * a moment before that end must be a no-op - no cycle 3, no order - because selection owns the
- * due-guard, anchored on the head cycle's immutable end. process() itself bills whatever cycle
- * it is handed, so the "is it due" decision lives in the selector, not the money-path.
- */
- public function test_scheduled_renewal_does_not_charge_ahead_of_the_period(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Bill cycle 2 (its period ends 2026-03-15).
- $order2 = $this->run_scheduled_renewal( $contract_id, new \DateTimeImmutable( '2026-02-15 00:00:00', new \DateTimeZone( 'UTC' ) ) );
- $this->assertInstanceOf( WC_Order::class, $order2 );
-
- $repo = new ContractRepository();
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertSame( 2, $head->get_count() );
- $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' ) ) );
- $this->assertNull( $ahead );
-
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 3 ) );
- $still = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $still );
- $this->assertSame( 2, $still->get_count() );
- }
-
- /**
- * @testdox process bills the cycle it is handed, even before that cycle's scheduled due date.
- *
- * process() owns no due policy: handed the next cycle directly (as a future admin or early
- * renewal trigger would), it bills it regardless of the scheduled due-guard - which lives only
- * in selection. A scheduled run at this same moment would skip; forcing the intent does not.
- */
- public function test_process_bills_the_handed_cycle_before_its_scheduled_due_date(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Cycle 1's period ends 2026-02-15; force cycle 2 well before then.
- $order = ( new RenewalEngine() )->process(
- new RenewalIntent( $contract_id, 2 ),
- new \DateTimeImmutable( '2026-01-20 00:00:00', new \DateTimeZone( 'UTC' ) )
- );
-
- $this->assertInstanceOf( WC_Order::class, $order );
- $this->assertTrue( $order->is_paid() );
-
- $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( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
- /**
- * @testdox renew_now forces the next cycle before its scheduled due date, keeping the schedule.
- *
- * The scheduled path would defer (the head's period has not ended), but an admin renewal
- * bypasses the due-guard. The forced cycle continues from the previous period's end, so the
- * schedule is preserved (a prepay), not reset to the moment of the manual renewal.
- */
- public function test_renew_now_forces_the_next_cycle_before_its_due_date(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // A moment well before cycle 1's period end (2026-02-15): not yet due on the schedule.
- $now = new \DateTimeImmutable( '2026-02-01 00:00:00', new \DateTimeZone( 'UTC' ) );
-
- $renewal_order = ( new RenewalEngine() )->renew_now( $contract_id, $now );
-
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertTrue( $renewal_order->is_paid() );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $cycle );
- $this->assertSame( 2, $cycle->get_count() );
- $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.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox renew_now retries a failed head in place and bills it on success.
- *
- * A failed renewal (the gateway declined) is not re-selected by the scheduled path, but an
- * admin retry re-attempts the SAME cycle: it flips failed -> pending, reuses the failed order,
- * and settles billed once the charge succeeds - without advancing to a new cycle.
- */
- public function test_renew_now_retries_a_failed_head_in_place(): void {
- $this->fail_charges_for( self::GATEWAY_DECLINING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_DECLINING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $engine = new RenewalEngine();
-
- // Scheduled run advances to cycle 2, which the gateway declines: a failed head.
- $this->run_scheduled_renewal( $contract_id );
-
- $repo = new ContractRepository();
- $failed = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $failed );
- $this->assertSame( 2, $failed->get_count() );
- $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 );
- add_action(
- 'woocommerce_subscriptions_engine_scheduled_payment_' . self::GATEWAY_DECLINING,
- static function ( $amount, $renewal_order ): void {
- unset( $amount );
- if ( $renewal_order instanceof WC_Order && $renewal_order->needs_payment() ) {
- $renewal_order->payment_complete();
- }
- },
- 10,
- 2
- );
-
- $retry = $engine->renew_now( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $retry );
- $this->assertTrue( $retry->is_paid() );
-
- // The SAME cycle 2 is now billed - retried in place, not advanced to a cycle 3.
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertSame( 2, $head->get_count() );
- $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 ) );
- }
-
- /**
- * @testdox renew_now returns null for a contract with no billing chain, and does not park it.
- *
- * Unlike the scheduled path, a manual renewal never clears the schedule when it cannot proceed.
- */
- public function test_renew_now_returns_null_for_a_chainless_contract_without_parking(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
-
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
-
- // The schedule is untouched (not parked): next_payment remains as set.
- $reloaded = ( new ContractRepository() )->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox the scheduled scan resumes a draft order already linked on the cycle: promotes, charges, settles.
- *
- * A crash between linking the draft onto the cycle and promoting it to pending leaves a
- * linked `checkout-draft`. The resume path must resolve it directly through the cycle's
- * order reference, promote it, charge it, and settle - one order, no duplicate.
- */
- public function test_scheduled_renewal_resumes_a_linked_draft_order(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
-
- $cycle = $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
- $draft = $this->make_ghost_renewal_order( $contract_id, 2, false, 'checkout-draft' );
- $cycle->set_order_id( $draft->get_id() );
- $repo->update_cycle( $cycle );
-
- $resumed = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $resumed );
- $this->assertSame( $draft->get_id(), $resumed->get_id(), 'The linked draft is reused, not duplicated.' );
- $this->assertTrue( $resumed->is_paid() );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
- /**
- * @testdox the scheduled scan finds an unlinked draft via the meta fallback and heals the cycle link.
- *
- * A crash between saving the draft and linking it onto the cycle leaves an unlinked
- * `checkout-draft` carrying only the renewal meta. The fallback search must still surface
- * it (drafts are excluded from a plain 'any'-status query), heal the link, and resume.
- */
- public function test_scheduled_renewal_reuses_an_unlinked_draft_order(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
-
- $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
- $draft = $this->make_ghost_renewal_order( $contract_id, 2, false, 'checkout-draft' );
-
- $resumed = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $resumed );
- $this->assertSame( $draft->get_id(), $resumed->get_id(), 'The abandoned draft is reused, not duplicated.' );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $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.' );
- }
-
- /**
- * @testdox a fresh claim builds its own order and never adopts a pre-tagged stray.
- *
- * The order lookup runs only for a reclaimed cycle: a cycle appended by this run cannot
- * have an order yet, so the every-renewal path skips the meta scan - and an anomalous
- * stray order carrying matching renewal meta (no cycle was ever claimed for it) is not
- * adopted or charged.
- */
- public function test_a_fresh_claim_builds_its_own_order_and_ignores_a_pre_tagged_stray(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // A stray order tagged for the not-yet-claimed cycle 2 (a data anomaly: order work
- // always follows the claim, so nothing legitimate produces this).
- $stray = $this->make_ghost_renewal_order( $contract_id, 2, false );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertNotSame( $stray->get_id(), $renewal_order->get_id(), 'The fresh claim does not adopt the stray.' );
- $this->assertTrue( $renewal_order->is_paid() );
-
- $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( 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() );
- $this->assertInstanceOf( WC_Order::class, $stray_after );
- $this->assertFalse( $stray_after->is_paid(), 'The stray order is never charged.' );
- }
-
- /**
- * @testdox complete_from_order settles once: repeats move no dates and re-fire no actions.
- */
- public function test_complete_from_order_settles_the_schedule_once_when_invoked_repeatedly(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $billed_fired = 0;
- add_action(
- RenewalEngine::RENEWAL_BILLED_ACTION,
- static function () use ( &$billed_fired ): void {
- ++$billed_fired;
- },
- 10,
- 0
- );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $engine = new RenewalEngine();
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $fired_after_process = $billed_fired;
-
- $repo = new ContractRepository();
- $after = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $after );
- $next_payment = $after->get_next_payment_gmt();
- $last_payment = $after->get_last_payment_gmt();
-
- // A late duplicate completion (a repeated webhook, a concurrent worker's retry): no-op.
- $fresh = wc_get_order( $renewal_order->get_id() );
- $this->assertInstanceOf( WC_Order::class, $fresh );
- $engine->complete_from_order( $fresh );
- $fired_after_repeat = $billed_fired;
-
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( $next_payment, $reloaded->get_next_payment_gmt(), 'The schedule does not move again.' );
- $this->assertSame( $last_payment, $reloaded->get_last_payment_gmt(), 'The payment record does not move again.' );
- $this->assertSame( 1, $fired_after_process, 'The billed action fires exactly once for the renewal.' );
- $this->assertSame( 1, $fired_after_repeat, 'The billed action does not re-fire.' );
- }
-
- /**
- * @testdox complete_from_order ignores an order the head is not linked to.
- *
- * The cycle's own order reference is the settlement authority: a rogue paid order carrying
- * matching renewal meta must not settle a head that is linked to a different order.
- */
- public function test_complete_from_order_ignores_an_order_the_head_is_not_linked_to(): void {
- GatewayCapabilities::declare( self::GATEWAY_APPROVING, array( GatewayCapabilities::RECURRING ) );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
-
- // The head is claimed with a live lease and linked to (unpaid) order A.
- $cycle = $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() + 3600 ) );
- $order_a = $this->make_ghost_renewal_order( $contract_id, 2, false );
- $cycle->set_order_id( $order_a->get_id() );
- $repo->update_cycle( $cycle );
-
- // A rogue paid order B carries the same renewal meta but is not the head's order.
- $order_b = $this->make_ghost_renewal_order( $contract_id, 2, true );
- ( new RenewalEngine() )->complete_from_order( $order_b );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $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() );
- }
-
- /**
- * @testdox complete_from_order leaves the cycle in flight when the order cannot be re-read.
- *
- * Settlement never trusts the stale in-memory order: when the fresh read fails (the order
- * was deleted mid-flight) the cycle stays as it was, for a later run to resolve.
- */
- public function test_complete_from_order_leaves_the_cycle_in_flight_when_the_order_is_gone(): void {
- GatewayCapabilities::declare( self::GATEWAY_APPROVING, array( GatewayCapabilities::RECURRING ) );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
-
- $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() + 3600 ) );
- $ghost = $this->make_ghost_renewal_order( $contract_id, 2, true );
- $ghost->delete( true );
-
- // The in-memory instance still carries the meta, but the stored order is gone.
- ( new RenewalEngine() )->complete_from_order( $ghost );
-
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertTrue( $head->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ), 'No settlement happens from a stale order copy.' );
- }
-
- /**
- * @testdox the scheduled scan parks a contract whose gateway cannot charge renewals.
- *
- * A gateway that does not declare the `recurring` capability makes every attempt futile
- * until the payment method is updated: the charge hook would fire into nothing and the
- * cycle would stall `processing`. The pre-flight refuses before any claim, and the
- * dispatcher parks the contract out of the due set; fixing the payment method plus a
- * manual renewal (or a repair) re-arms it.
- */
- public function test_scheduled_renewal_parks_a_contract_whose_gateway_cannot_charge(): void {
- // Signed up against a gateway that never declares the `recurring` capability.
- $contract = $this->sign_up_contract( self::GATEWAY );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
-
- // Nothing was claimed: the head is still cycle 1 and no renewal order exists.
- $repo = new ContractRepository();
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertSame( 1, $head->get_count() );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- // Parked: the contract left the due set until the payment method is repaired.
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertNull( $reloaded->get_next_payment_gmt(), 'An unchargeable contract is parked out of the due set.' );
- }
-
- /**
- * @testdox renew_now refuses a contract whose gateway cannot charge, without parking it.
- */
- public function test_renew_now_refuses_an_unchargeable_gateway_without_parking(): void {
- $contract = $this->sign_up_contract( self::GATEWAY );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
-
- // No claim, no order, and the schedule is untouched (a manual action never parks).
- $repo = new ContractRepository();
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $this->assertSame( 1, $head->get_count() );
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
-
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * Create an active, overdue contract through the facade, minus the given renewal input.
- *
- * @param string $missing One of currency, customer, payment_method, billing_policy.
- */
- private function seed_contract_missing( string $missing ): int {
- $customer = self::factory()->user->create();
- $this->assertIsInt( $customer );
-
- $args = array(
- 'extension_slug' => 'engine-tests',
- 'customer_id' => $customer,
- 'currency' => 'USD',
- 'payment_method' => self::GATEWAY_APPROVING,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2026-02-01 00:00:00',
- 'billing_total' => '10',
- 'selling_plan_id' => $this->make_plan(),
- );
-
- switch ( $missing ) {
- case 'currency':
- unset( $args['currency'], $args['billing_total'] );
- break;
- case 'customer':
- unset( $args['customer_id'] );
- break;
- case 'billing_policy':
- unset( $args['selling_plan_id'] );
- break;
- default:
- unset( $args[ $missing ] );
- }
-
- $id = Contracts::create( $args )->get_id();
- Contracts::add_cycle(
- $id,
- array(
- 'status' => CycleStatus::BILLED,
- 'count' => 1,
- 'currency' => 'USD',
- 'starts_at_gmt' => '2026-01-01 00:00:00',
- 'ends_at_gmt' => '2026-02-01 00:00:00',
- )
- );
- Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) );
-
- return $id;
- }
-
- /**
- * @return array<string, array{0: string}>
- */
- public function provide_missing_renewal_inputs(): array {
- return array(
- 'currency' => array( 'currency' ),
- 'payment method' => array( 'payment_method' ),
- 'billing policy' => array( 'billing_policy' ),
- );
- }
-
- /**
- * @testdox the scheduled scan parks a contract missing a renewal input instead of erroring.
- * @dataProvider provide_missing_renewal_inputs
- *
- * @param string $missing The missing renewal input.
- */
- public function test_scheduled_renewal_parks_a_contract_missing_a_renewal_input( string $missing ): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- $contract_id = $this->seed_contract_missing( $missing );
-
- $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
-
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- $this->assertNull( $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'Parked out of the due set.' );
- }
-
- /**
- * @testdox a contract with no customer renews as a guest order.
- */
- public function test_a_contract_without_a_customer_renews_as_a_guest_order(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- $contract_id = $this->seed_contract_missing( 'customer' );
-
- $order = $this->run_scheduled_renewal( $contract_id );
-
- $this->assertInstanceOf( WC_Order::class, $order );
- $this->assertSame( 0, $order->get_customer_id() );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- $this->assertSame( '2026-03-01 00:00:00', $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'The next-due moment advanced.' );
- }
-
- /**
- * @testdox renew_now refuses a contract missing a renewal input without parking it.
- * @dataProvider provide_missing_renewal_inputs
- *
- * @param string $missing The missing renewal input.
- */
- public function test_renew_now_refuses_a_contract_missing_a_renewal_input( string $missing ): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
- $contract_id = $this->seed_contract_missing( $missing );
-
- $this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
-
- $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
- $this->assertSame( '2026-02-01 00:00:00', $this->reload_contract( $contract_id )->get_next_payment_gmt() );
- }
-
- /**
- * @testdox a manual paid-status change settles a processing cycle (cash-on-delivery shape).
- *
- * A gateway-less settlement never calls payment_complete(): an admin marks the renewal
- * order processing/completed by hand. The paid-status transition listeners must complete
- * the cycle from that, or manual methods stay locked in `processing` forever.
- */
- public function test_manual_paid_status_change_settles_the_cycle(): void {
- GatewayCapabilities::declare( self::GATEWAY_PENDING, array( GatewayCapabilities::RECURRING ) );
-
- $contract = $this->sign_up_contract( self::GATEWAY_PENDING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // No charge handler: the charge stays unconfirmed and the cycle parks `processing`.
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
-
- $repo = new ContractRepository();
- $head = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $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( 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 );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- }
-
- /**
- * @testdox renewal order lines carry the paid quantity whatever the pricing payload says.
- */
- public function test_renewal_order_lines_carry_the_paid_quantity_whatever_the_pricing_payload(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract_with_line_item(
- self::GATEWAY_APPROVING,
- array(
- 'policies' => array(
- array( 'type' => 'bogo' ),
- ),
- )
- );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertTrue( $renewal_order->is_paid() );
-
- // The engine interprets no pricing vocabulary: paid quantity, stored amounts.
- $items = array_values( $renewal_order->get_items() );
- $this->assertCount( 1, $items );
- $item = $items[0];
- $this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
- $this->assertSame( 2, $item->get_quantity() );
- $this->assertSame( 39.98, (float) $item->get_subtotal() );
- $this->assertSame( 39.98, (float) $item->get_total() );
- $this->assertSame( 39.98, (float) $renewal_order->get_total() );
- }
-
- /**
- * @testdox the renewal-order-created action receives the built order, linked on its cycle, and the contract.
- */
- public function test_renewal_order_created_action_receives_the_built_order_and_contract(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract_with_line_item( self::GATEWAY_APPROVING, null );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $calls = array();
- add_action(
- RenewalEngine::RENEWAL_ORDER_CREATED_ACTION,
- static function ( $order, $hooked_contract ) use ( &$calls ): void {
- $head = ( new ContractRepository() )->find_chain_head( (int) $hooked_contract->get_id() );
- $calls[] = array(
- 'order' => $order,
- 'contract' => $hooked_contract,
- 'head_order_id' => $head instanceof Cycle ? $head->get_order_id() : null,
- );
- },
- 10,
- 2
- );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
-
- $this->assertCount( 1, $calls, 'The action fires once for a new renewal order.' );
- $this->assertInstanceOf( WC_Order::class, $calls[0]['order'] );
- $this->assertSame( $renewal_order->get_id(), $calls[0]['order']->get_id() );
- $this->assertInstanceOf( Contract::class, $calls[0]['contract'] );
- $this->assertSame( $contract_id, $calls[0]['contract']->get_id() );
-
- // The order is already linked on its cycle when listeners run.
- $this->assertSame( $renewal_order->get_id(), $calls[0]['head_order_id'] );
-
- $items = array_values( $calls[0]['order']->get_items() );
- $this->assertCount( 1, $items );
- $this->assertSame( 2, $items[0]->get_quantity() );
- }
-
- /**
- * @testdox the renewal-order-created action does not re-fire for a resumed order, and fires once for a fresh one.
- */
- public function test_renewal_order_created_action_is_not_refired_for_a_reused_order(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $previous = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $previous );
- $this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
- $ghost = $this->make_ghost_renewal_order( $contract_id, 2, false );
-
- $fired = new \ArrayObject();
- add_action(
- RenewalEngine::RENEWAL_ORDER_CREATED_ACTION,
- static function ( $order ) use ( $fired ): void {
- $fired->append( $order->get_id() );
- }
- );
-
- $resumed = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $resumed );
- $this->assertSame( $ghost->get_id(), $resumed->get_id() );
- $this->assertSame( array(), $fired->getArrayCopy(), 'A reused order does not re-fire the action.' );
-
- $fresh = $this->run_scheduled_renewal( $contract_id, new \DateTimeImmutable( '2026-03-16 00:00:00', new \DateTimeZone( 'UTC' ) ) );
- $this->assertInstanceOf( WC_Order::class, $fresh );
- $this->assertNotSame( $ghost->get_id(), $fresh->get_id() );
- $this->assertSame( array( $fresh->get_id() ), $fired->getArrayCopy(), 'A freshly built order fires the action once.' );
- }
-
- /**
- * @testdox renewal order lines keep the stored quantity: $label.
- * @dataProvider provide_unclamped_quantities
- *
- * @param string $label Case label.
- * @param int|float $quantity Origin line quantity.
- * @param bool $fractional Whether a fractional stock-amount filter is active.
- */
- public function test_renewal_order_lines_keep_the_stored_quantity( string $label, $quantity, bool $fractional ): void {
- if ( $fractional ) {
- remove_filter( 'woocommerce_stock_amount', 'intval' );
- add_filter( 'woocommerce_stock_amount', 'floatval' );
- }
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract_with_line_item( self::GATEWAY_APPROVING, null, $quantity );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order, $label );
-
- $items = array_values( $renewal_order->get_items() );
- $this->assertCount( 1, $items );
- $this->assertInstanceOf( \WC_Order_Item_Product::class, $items[0] );
- $this->assertEquals( $quantity, $items[0]->get_quantity(), $label );
- }
-
- /**
- * Quantities the engine must not floor or round.
- *
- * @return array<string, array{0: string, 1: int|float, 2: bool}>
- */
- public function provide_unclamped_quantities(): array {
- return array(
- 'zero' => array( 'zero', 0, false ),
- 'fractional' => array( 'fractional', 1.5, true ),
- );
- }
-
- /**
- * Sign up a contract from an order carrying a real product line (quantity 2 by default,
- * USD 39.98) on a monthly plan with the given pricing payload.
- *
- * @param string $gateway Gateway id stamped on the order/contract.
- * @param array<string, mixed>|null $pricing_policy The plan's pricing payload, or null for none.
- * @param int|float $quantity Origin line quantity.
- * @return Contract The persisted contract with cycle 1 billed.
- */
- private function sign_up_contract_with_line_item( string $gateway, ?array $pricing_policy, $quantity = 2 ): Contract {
- $plan = $this->plan_view( $this->make_plan( array( 'pricing_policy' => $pricing_policy ) ) );
-
- $product = new \WC_Product_Simple();
- $product->set_name( 'Monthly Filters' );
- $product->set_regular_price( '19.99' );
- $product_id = (int) $product->save();
-
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( $gateway );
- $order->set_total( '39.98' );
- $order->set_date_paid( '2026-01-15 00:00:00' );
- $line = new \WC_Order_Item_Product();
- $line->set_name( 'Monthly Filters' );
- $line->set_product_id( $product_id );
- $line->set_quantity( $quantity ); // @phpstan-ignore argument.type (docblock-only int; fractional quantities are valid)
- $line->set_subtotal( '39.98' );
- $line->set_total( '39.98' );
- $order->add_item( $line );
- $order->save();
-
- return $this->reload_contract( $this->sign_up_from_order( $order, $plan ) );
- }
-
- /**
- * Append a pending cycle 2 (no tagged renewal order) with the given crash-recovery
- * lease, so the create-as-claim collides on it and the reclaim-vs-skip path is exercised.
- *
- * @param int $contract_id Contract id.
- * @param Cycle $previous The chain's current cycle (cycle 1).
- * @param string $claimed_until The lease expiry GMT string to stamp on the pending cycle.
- * @return Cycle The appended pending cycle 2.
- */
- private function make_pending_cycle_2( int $contract_id, Cycle $previous, string $claimed_until ): Cycle {
- $cycle = Cycle::create(
- array(
- 'contract_id' => $contract_id,
- 'sequence_no' => $previous->get_sequence_no() + 1,
- 'count' => 2,
- 'status' => new CycleStatus( CycleStatus::PENDING ),
- 'starts_at_gmt' => '2026-02-15 00:00:00',
- 'ends_at_gmt' => '2026-03-15 00:00:00',
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- 'claimed_until' => $claimed_until,
- )
- );
- ( new ContractRepository() )->append_cycle( $cycle, $previous );
-
- return $cycle;
- }
-
- /**
- * A renewal order left as a crash would leave it: saved with the renewal-relation meta for
- * `$count` but never charged (no payment token). Optionally pre-marked paid, to model a crash
- * AFTER the gateway was paid but before the cycle settled, or given an explicit status - a
- * `checkout-draft` models a crash during the draft-first creation window.
- *
- * @param int $contract_id Contract id.
- * @param int $count Chargeable number the order bills.
- * @param bool $paid Whether the gateway had already been paid before the crash.
- * @param string|null $status Explicit order status, overriding the paid/pending default.
- * @return WC_Order The ghost renewal order.
- */
- private function make_ghost_renewal_order( int $contract_id, int $count, bool $paid, ?string $status = null ): WC_Order {
- // A ghost models a crash where settlement never ran: silence the order-settled
- // listeners while seeding it (a paid status transition would settle the cycle
- // mid-setup), then restore them on a fresh engine.
- remove_all_actions( 'woocommerce_payment_complete' );
- remove_all_actions( 'woocommerce_order_status_failed' );
- foreach ( wc_get_is_paid_statuses() as $paid_status ) {
- remove_all_actions( 'woocommerce_order_status_' . $paid_status );
- }
-
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_total( '19.99' );
- $order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
- $order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
- $order->update_meta_data( '_subscription_renewal_cycle', (string) $count );
-
- if ( $paid ) {
- $order->set_status( 'processing' );
- $order->set_date_paid( '2026-02-15 00:00:00' );
- } else {
- $order->set_status( 'pending' );
- }
- if ( null !== $status ) {
- $order->set_status( $status );
- }
- $order->save();
-
- ( new RenewalEngine() )->register_hooks();
-
- return $order;
- }
-
- /**
- * Renewal orders tagged for a contract at a given chargeable number, narrowed
- * in PHP (store-agnostic, like the engine's own idempotency check).
- *
- * @param int $contract_id Contract id.
- * @param int $count Chargeable number.
- * @return array<int, WC_Order>
- */
- private function renewal_orders_for_cycle( int $contract_id, int $count ): array {
- $orders = wc_get_orders(
- array(
- 'limit' => -1,
- 'type' => 'shop_order',
- 'status' => 'any',
- '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
- )
- );
-
- return array_values(
- array_filter(
- is_array( $orders ) ? $orders : array(),
- static function ( $order ) use ( $count ) {
- return $order instanceof WC_Order
- && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
- && (string) $count === $order->get_meta( '_subscription_renewal_cycle' );
- }
- )
- );
- }
-}
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 5fc3bb0a965..22fd0b3330a 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
@@ -10,16 +10,17 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\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\PaymentInstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
@@ -102,33 +103,69 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
/**
* Build a billing cycle for the given contract at a sequence/count.
*
- * @param int $contract_id Contract id.
- * @param int $sequence_no Position in the chain.
- * @param int|null $count Chargeable count, or null for non-counting.
- * @param string $starts_at Period start GMT string.
- * @param string $ends_at Period end GMT string.
- * @param PlanSnapshot|null $plan Plan snapshot, or null.
- * @param ItemsSnapshot|null $items Items snapshot, or null.
- * @param int|null $order_id Linked order id, or null.
- */
- private function make_cycle( int $contract_id, int $sequence_no, ?int $count, string $starts_at, string $ends_at, ?PlanSnapshot $plan = null, ?ItemsSnapshot $items = null, ?int $order_id = null ): Cycle {
+ * @param int $contract_id Contract id.
+ * @param int $sequence_no Position in the chain.
+ * @param int|null $count Chargeable count, or null for non-counting.
+ * @param string $starts_at Period start GMT string.
+ * @param string $ends_at Period end GMT string.
+ * @param int|null $plan_snapshot_id Stored plan snapshot id, or null.
+ * @param int|null $items_snapshot_id Stored items snapshot id, or null.
+ * @param int|null $order_id Linked order id, or null.
+ */
+ private function make_cycle( int $contract_id, int $sequence_no, ?int $count, string $starts_at, string $ends_at, ?int $plan_snapshot_id = null, ?int $items_snapshot_id = null, ?int $order_id = null ): Cycle {
return Cycle::create(
array(
- 'contract_id' => $contract_id,
- 'sequence_no' => $sequence_no,
- 'count' => $count,
- 'starts_at_gmt' => $starts_at,
- 'ends_at_gmt' => $ends_at,
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- 'extension_slug' => 'lite',
- 'order_id' => $order_id,
- 'plan_snapshot' => $plan,
- 'items_snapshot' => $items,
+ 'contract_id' => $contract_id,
+ 'sequence_no' => $sequence_no,
+ 'count' => $count,
+ 'starts_at_gmt' => $starts_at,
+ 'ends_at_gmt' => $ends_at,
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'extension_slug' => 'lite',
+ 'order_id' => $order_id,
+ 'plan_snapshot_id' => $plan_snapshot_id,
+ 'items_snapshot_id' => $items_snapshot_id,
+ )
+ );
+ }
+
+ /**
+ * Build a first billing-style cycle on a chain of the given kind.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $kind Chain kind.
+ */
+ private function make_cycle_of_kind( int $contract_id, string $kind ): Cycle {
+ return Cycle::create(
+ array(
+ 'contract_id' => $contract_id,
+ 'kind' => $kind,
+ 'sequence_no' => 1,
+ 'starts_at_gmt' => '2026-07-15 00:00:00',
+ 'ends_at_gmt' => '2026-08-15 00:00:00',
+ 'currency' => 'USD',
)
);
}
+ /**
+ * Store the sample plan and items snapshot rows for a contract.
+ *
+ * @param int $contract_id Contract id.
+ * @return array{0: int, 1: int} The plan and items snapshot ids.
+ */
+ private function seed_cycle_snapshots( int $contract_id ): array {
+ $store = new SnapshotStore();
+ $plan = $this->sample_plan_snapshot();
+ $items = $this->sample_items_snapshot();
+
+ return array(
+ $store->insert( $contract_id, SnapshotStore::TYPE_PLAN, $plan->get_selling_plan_id(), $plan->to_payload(), $plan->get_schema_version() ),
+ $store->insert( $contract_id, SnapshotStore::TYPE_ITEMS, null, $items->to_payload(), $items->get_schema_version() ),
+ );
+ }
+
private function sample_plan_snapshot(): PlanSnapshot {
return PlanSnapshot::from_array(
array(
@@ -270,7 +307,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( 'pdp', $this->sut->get_meta( $id, 'source_channel', true ) );
$contract->set_status( ContractStatus::ACTIVE );
- $this->assertTrue( $this->sut->update_if_status( $contract, ContractStatus::ON_HOLD ) );
+ $this->assertTrue( $this->sut->update_fields( $contract, array( 'status' ) ) );
$this->assertSame( array( 'pdp' ), $this->sut->get_meta( $id, 'source_channel' ) );
}
@@ -888,7 +925,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
public function test_append_cycle_and_find_chain_head(): void {
$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() );
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) );
$this->sut->append_cycle( $cycle );
$this->assertNotNull( $cycle->get_id() );
@@ -898,12 +935,12 @@ 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( new CycleStatus( CycleStatus::PENDING ) ) );
+ $this->assertSame( CycleStatus::PENDING, $current->get_status()->get_value() );
$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() );
- // Snapshots decoded back into typed value objects on an in-flight cycle.
+ // Stored snapshot ids are decoded back into typed value objects on read.
$this->assertInstanceOf( PlanSnapshot::class, $current->get_plan_snapshot() );
$this->assertSame(
array(
@@ -952,7 +989,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->sut->append_cycle( $first );
$second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' );
- $this->sut->append_cycle( $second, $first );
+ $this->sut->append_cycle( $second );
$current = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $current );
@@ -974,11 +1011,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
public function test_find_cycle_history_pages_newest_first(): void {
$id = $this->sut->insert( $this->make_contract() );
- $prev = null;
for ( $n = 1; $n <= 3; $n++ ) {
- $cycle = $this->make_cycle( $id, $n, $n, sprintf( '2026-%02d-15 00:00:00', 6 + $n ), sprintf( '2026-%02d-15 00:00:00', 7 + $n ) );
- $this->sut->append_cycle( $cycle, $prev );
- $prev = $cycle;
+ $this->sut->append_cycle( $this->make_cycle( $id, $n, $n, sprintf( '2026-%02d-15 00:00:00', 6 + $n ), sprintf( '2026-%02d-15 00:00:00', 7 + $n ) ) );
}
$page = $this->sut->find_cycle_history( $id, Cycle::KIND_BILLING, 2, 0 );
@@ -992,7 +1026,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
}
/**
- * @testdox find_cycles_by_order_id returns every cycle linked to an order.
+ * @testdox find_cycles_by_order_id returns every cycle linked to an order, newest first.
*/
public function test_find_cycles_by_order_id(): void {
$first_id = $this->sut->insert( $this->make_contract() );
@@ -1013,57 +1047,26 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
},
$linked
);
- sort( $contract_ids );
- $this->assertSame( array( $first_id, $second_id ), $contract_ids );
- }
-
- /**
- * @testdox update_cycle persists a status transition on a stored cycle.
- */
- public function test_update_cycle_persists_a_status_change(): void {
- $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->sut->append_cycle( $cycle );
-
- $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( new CycleStatus( CycleStatus::BILLED ) ) );
+ $this->assertSame( array( $second_id, $first_id ), $contract_ids, 'Cycles come newest first.' );
}
/**
- * @testdox The cycle crash-recovery lease column round-trips through append and update.
+ * @testdox A claimed append stamps the cycle claim on the database clock; a transition without a claim releases it.
*/
public function test_cycle_claimed_until_round_trips(): void {
$id = $this->sut->insert( $this->make_contract() );
- // Appended with a lease set.
- $cycle = Cycle::create(
- array(
- 'contract_id' => $id,
- 'sequence_no' => 1,
- 'count' => 1,
- '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',
- 'currency' => 'USD',
- 'claimed_until' => '2026-07-15 00:15:00',
- )
- );
- $this->sut->append_cycle( $cycle );
+ // Appended claimed: the INSERT stamps the claim from the database clock.
+ $this->pin_db_clock( '2026-07-15 00:00:00' );
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
+ $this->sut->append_cycle( $cycle, 900 );
$reloaded = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $reloaded );
$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( new CycleStatus( CycleStatus::BILLED ) );
- $reloaded->set_claimed_until_gmt( null );
- $this->sut->update_cycle( $reloaded );
+ // Cleared by a transition without a claim (a settled cycle holds no claim).
+ $this->assertTrue( $this->sut->transition_cycle( (int) $reloaded->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array() ) );
$settled = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $settled );
@@ -1071,77 +1074,70 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
}
/**
- * @testdox reclaim_expired_cycle wins the CAS for an expired-lease pending cycle and extends the lease.
+ * @testdox claim_cycle takes an expired claim once; a second caller loses.
*
- * The expiry predicate and the fresh lease both anchor on the database clock, so the
- * test seeds the lease relative to real time.
+ * The expiry check and the new claim use the database clock, so the claim is seeded
+ * relative to real time.
*/
- public function test_reclaim_expired_cycle_succeeds_for_an_expired_lease(): void {
- $id = $this->sut->insert( $this->make_contract() );
- $cycle = $this->append_pending_cycle_with_lease( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+ public function test_claim_cycle_takes_an_expired_claim_once(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+ $cycle = $this->append_pending_cycle_with_claim( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+ $cycle_id = (int) $cycle->get_id();
- // The lease expired a minute ago per the DB clock: the CAS matches.
- $won = $this->sut->reclaim_expired_cycle( (int) $cycle->get_id(), 900 );
- $this->assertTrue( $won, 'The first worker reclaims an expired-lease pending cycle.' );
+ $this->assertTrue( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The first caller takes the expired claim.' );
+ $this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The second caller finds a live claim.' );
$reloaded = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $reloaded );
$this->assertNotNull( $reloaded->get_claimed_until_gmt() );
- $this->assertGreaterThan( time() + 800, strtotime( $reloaded->get_claimed_until_gmt() . ' UTC' ), 'The lease is extended a TTL into the future.' );
+ $this->assertGreaterThan( time() + 800, strtotime( $reloaded->get_claimed_until_gmt() . ' UTC' ), 'The claim runs a TTL into the future.' );
}
/**
- * @testdox reclaim_expired_cycle arbitrates the two-worker race: the first wins, the second loses.
- *
- * The compare-and-set that prevents a double charge. Both workers find the same
- * expired-lease cycle; the first CAS matches the row and extends the lease into the
- * future, so the second CAS (predicate `claimed_until <= now`) matches zero rows and
- * loses. Exactly one worker reclaims, so the cycle is charged at most once.
+ * @testdox claim_cycle takes a pending cycle that has no claim.
*/
- public function test_reclaim_expired_cycle_arbitrates_the_race(): void {
+ public function test_claim_cycle_takes_a_unclaimed_pending_cycle(): void {
$id = $this->sut->insert( $this->make_contract() );
- $cycle = $this->append_pending_cycle_with_lease( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+ $cycle = $this->append_pending_cycle_with_claim( $id, null );
- $cycle_id = (int) $cycle->get_id();
+ $this->assertTrue( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ) );
+ $this->assertFalse( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ) );
+ }
- // Worker A: the lease has expired per the DB clock, so the CAS wins and extends it.
- $first = $this->sut->reclaim_expired_cycle( $cycle_id, 900 );
- $this->assertTrue( $first, 'The first worker wins the reclaim.' );
+ /**
+ * @testdox claim_cycle refuses a live claim and a cycle that left the status the caller read.
+ */
+ public function test_claim_cycle_refuses_a_live_claim_and_a_settled_cycle(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+ $cycle = $this->append_pending_cycle_with_claim( $id, gmdate( 'Y-m-d H:i:s', time() + 600 ) );
- // Worker B: same read, but the lease now sits a TTL in the future - zero rows match.
- $second = $this->sut->reclaim_expired_cycle( $cycle_id, 900 );
- $this->assertFalse( $second, 'The second worker loses the race: no double reclaim.' );
+ $this->assertFalse( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ), 'A live claim is held by someone else.' );
- // The lease reflects the winner's extension.
- $reloaded = $this->sut->find_chain_head( $id );
- $this->assertInstanceOf( Cycle::class, $reloaded );
- $this->assertNotNull( $reloaded->get_claimed_until_gmt() );
- $this->assertGreaterThan( time() + 800, strtotime( $reloaded->get_claimed_until_gmt() . ' UTC' ) );
+ $this->assertTrue( $this->sut->transition_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, CycleStatus::BILLED, array() ) );
+ $this->assertFalse( $this->sut->claim_cycle( (int) $cycle->get_id(), CycleStatus::PENDING, 900 ), 'A cycle no longer in the read status is not claimable.' );
}
/**
- * @testdox reclaim_expired_cycle does not touch a settled (non-pending) cycle.
+ * @testdox claim_cycle claims a cycle in whatever status the caller read, not only pending.
*/
- public function test_reclaim_expired_cycle_skips_a_settled_cycle(): void {
- $id = $this->sut->insert( $this->make_contract() );
- $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( new CycleStatus( CycleStatus::BILLED ) );
- $cycle->set_claimed_until_gmt( null );
- $this->sut->update_cycle( $cycle );
+ public function test_claim_cycle_keys_on_the_callers_status(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+ $cycle_id = (int) $this->append_pending_cycle_with_claim( $id, null )->get_id();
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array() ) );
- $won = $this->sut->reclaim_expired_cycle( (int) $cycle->get_id(), 900 );
- $this->assertFalse( $won, 'A billed cycle is never reclaimable: the status predicate excludes it.' );
+ $this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'A stale read loses.' );
+ $this->assertTrue( $this->sut->claim_cycle( $cycle_id, CycleStatus::FAILED, 900 ), 'The current status wins.' );
+ $this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::FAILED, 900 ), 'Only once while the claim is live.' );
+ $this->assertSame( CycleStatus::FAILED, $this->find_cycle( $cycle_id )->get_status()->get_value(), 'Claiming does not change the status.' );
}
/**
- * Append a pending cycle 1 carrying a crash-recovery lease, returning it (with its id).
+ * Append a pending cycle 1 carrying a crash-recovery claim, returning it (with its id).
*
- * @param int $contract_id Contract id.
- * @param string $claimed_until The lease expiry GMT string to stamp.
+ * @param int $contract_id Contract id.
+ * @param string|null $claimed_until The claim expiry GMT string to stamp, or null for none.
*/
- private function append_pending_cycle_with_lease( int $contract_id, string $claimed_until ): Cycle {
+ private function append_pending_cycle_with_claim( int $contract_id, ?string $claimed_until ): Cycle {
$cycle = Cycle::create(
array(
'contract_id' => $contract_id,
@@ -1152,38 +1148,88 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'ends_at_gmt' => '2026-08-15 00:00:00',
'expected_total' => '19.99',
'currency' => 'USD',
- 'claimed_until' => $claimed_until,
)
);
$this->sut->append_cycle( $cycle );
+ $this->stamp_cycle_claim( (int) $cycle->get_id(), $claimed_until );
return $cycle;
}
/**
- * @testdox find_due returns only active contracts whose next_payment has arrived, oldest first.
+ * Seed a cycle's stored claim expiry directly (fixtures need claims in the past).
+ *
+ * @param int $cycle_id Cycle id.
+ * @param string|null $claimed_until Claim expiry GMT string, or null for none.
*/
- public function test_find_due_returns_only_due_active_contracts_oldest_first(): void {
- $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ private function stamp_cycle_claim( int $cycle_id, ?string $claimed_until ): void {
+ global $wpdb;
- $due_old = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $due_recent = $this->insert_contract_due_at( '2026-07-14 00:00:00', ContractStatus::ACTIVE );
- $not_yet = $this->insert_contract_due_at( '2026-08-15 00:00:00', ContractStatus::ACTIVE );
- $on_hold = $this->insert_contract_due_at( '2026-06-01 00:00:00', ContractStatus::ON_HOLD );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ), array( 'claimed_until' => $claimed_until ), array( 'id' => $cycle_id ) );
+ }
- $ids = $this->due_ids( $now, 50 );
+ /**
+ * @testdox A claimed append is one INSERT: the claim is stamped in the same statement, with no read or follow-up write.
+ */
+ public function test_a_claimed_append_is_one_insert(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $cycles = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $queries = array();
+ $capture = static function ( string $query ) use ( &$queries ): string {
+ $queries[] = $query;
+ return $query;
+ };
+
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
+ add_filter( 'query', $capture );
+ try {
+ $this->sut->append_cycle( $cycle, 900 );
+ } finally {
+ remove_filter( 'query', $capture );
+ }
+
+ $writes = array_values(
+ array_filter(
+ $queries,
+ static function ( string $query ) use ( $cycles ): bool {
+ return false !== strpos( $query, $cycles ) || false !== stripos( $query, 'UTC_TIMESTAMP' );
+ }
+ )
+ );
+ $this->assertCount( 1, $writes, 'Only the INSERT touches the cycles table or the database clock.' );
+ $this->assertStringStartsWith( "INSERT INTO `{$cycles}`", $writes[0] );
+ $this->assertStringContainsString( 'DATE_ADD( UTC_TIMESTAMP(), INTERVAL 900 SECOND )', $writes[0] );
+ }
+
+ /**
+ * @testdox find_due returns due contracts whatever their status, oldest first.
+ */
+ public function test_find_due_is_status_blind_oldest_first(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
- // 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 );
+ $on_hold = $this->insert_contract_due_at( '2026-06-01 00:00:00', ContractStatus::ON_HOLD );
+ $due_old = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+ $pending_cxl = $this->insert_contract_due_at( '2026-06-20 00:00:00', ContractStatus::PENDING_CANCELLATION );
+ $extension = $this->insert_contract_due_at( '2026-06-25 00:00:00', 'paused-by-merchant' );
+ $draft = $this->insert_contract_due_at( '2026-07-01 00:00:00', ContractStatus::DRAFT );
+ $due_recent = $this->insert_contract_due_at( '2026-07-14 00:00:00', ContractStatus::ACTIVE );
+ $not_yet = $this->insert_contract_due_at( '2026-08-15 00:00:00', ContractStatus::ACTIVE );
+
+ $this->assertSame(
+ array( $on_hold, $due_old, $pending_cxl, $extension, $draft, $due_recent ),
+ $this->due_ids( $now, 50 ),
+ 'Every due contract is selected whatever its status; whether it acts is the owner\'s decision.'
+ );
+ $this->assertNotContains( $not_yet, $this->due_ids( $now, 50 ) );
}
/**
- * @testdox find_due skips a due contract whose stored status is not registered.
+ * @testdox find_due selects a due contract whose stored status is not registered.
*/
- public function test_find_due_skips_a_contract_with_an_unregistered_stored_status(): void {
+ public function test_find_due_selects_a_contract_with_an_unregistered_stored_status(): void {
global $wpdb;
$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
@@ -1191,7 +1237,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
// 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 ) );
+ $this->assertSame( array( $id ), $this->due_ids( $now, 50 ) );
}
/**
@@ -1213,11 +1259,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
}
/**
- * @testdox find_due skips a contract whose owner is not registered, and selects it untouched once it registers.
+ * @testdox find_due skips a contract whose owner is not listed, and selects it untouched once it is.
*/
- public function test_find_due_skips_an_unregistered_owner_until_it_registers(): void {
+ public function test_find_due_skips_an_unlisted_owner_until_it_is_listed(): 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' );
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_ENGINE, CycleStatus::BILLED, null, null, 'other-ext' );
$this->assertNotContains( $id, $this->due_ids( $now, 50 ) );
@@ -1228,9 +1274,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
}
/**
- * @testdox find_due returns nothing when no consumer is registered.
+ * @testdox find_due returns nothing when no owner is listed.
*/
- public function test_find_due_returns_nothing_when_no_consumer_is_registered(): void {
+ public function test_find_due_returns_nothing_without_owners(): 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 );
@@ -1262,162 +1308,512 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$second = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
$this->insert_contract_due_at( '2026-07-01 00:00:00', ContractStatus::ACTIVE );
- // The limit caps the batch from the oldest-due end; the newest-due row is left for a later tick.
$this->assertSame( array( $first, $second ), $this->due_ids( $now, 2 ) );
}
/**
- * @testdox find_due excludes gateway-scheduled contracts (the gateway owns their renewal).
+ * @testdox find_due selects only contracts whose schedule the engine keeps.
*/
- public function test_find_due_excludes_gateway_scheduled_contracts(): void {
+ public function test_find_due_selects_only_engine_scheduled_contracts(): void {
$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
- $primitive = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $gateway = $this->insert_contract_due_at( '2026-06-01 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_GATEWAY );
+ $engine = $this->insert_contract_due_at( '2026-06-01 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_ENGINE );
+ $gateway = $this->insert_contract_due_at( '2026-05-01 00:00:00', ContractStatus::ACTIVE, 'my_gateway' );
+ $legacy = $this->insert_contract_due_at( '2026-05-15 00:00:00', ContractStatus::ACTIVE, 'primitive' );
$ids = $this->due_ids( $now, 50 );
- $this->assertContains( $primitive, $ids );
- $this->assertNotContains( $gateway, $ids, 'A gateway-scheduled contract must never enter the primitive scan.' );
+ $this->assertSame( array( $engine ), $ids );
+ $this->assertNotContains( $gateway, $ids, 'A contract whose schedule a gateway keeps is never dispatched.' );
+ $this->assertNotContains( $legacy, $ids, 'Only the engine source is dispatched.' );
}
/**
- * @testdox find_due returns the head fields the selector needs.
+ * @testdox find_due returns the id, owner and stored due moment of each row.
*/
- public function test_find_due_returns_the_head_fields(): void {
- $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ public function test_find_due_returns_the_row_shape(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 02:00:00', new \DateTimeZone( 'Europe/Berlin' ) );
+
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+
+ $this->assertSame(
+ array(
+ array(
+ 'id' => $id,
+ 'extension_slug' => self::OWNER,
+ 'next_payment_gmt' => '2026-06-15 00:00:00',
+ ),
+ ),
+ $this->sut->find_due( $now, 50, array( self::OWNER ) )
+ );
+ }
- $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $candidates = $this->sut->find_due( $now, 50, array( self::OWNER ) );
+ /**
+ * @testdox find_due returns no rows for a non-positive limit.
+ */
+ public function test_find_due_returns_no_rows_for_a_non_positive_limit(): 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->assertCount( 1, $candidates );
- $row = $candidates[0];
- $this->assertInstanceOf( RenewalCandidate::class, $row );
- $this->assertSame( $id, $row->get_contract_id() );
- $this->assertSame( 1, $row->get_head_count() );
- $this->assertSame( CycleStatus::BILLED, $row->get_head_status() );
- $this->assertSame( '2026-06-15 00:00:00', $row->get_head_ends_at_gmt() );
+ $this->assertSame( array(), $this->sut->find_due( $now, 0, array( self::OWNER ) ) );
+ $this->assertSame( array(), $this->sut->find_due( $now, -1, array( self::OWNER ) ) );
}
/**
- * @testdox find_due excludes a contract whose head is not actionable (failed / processing).
+ * @testdox A live dispatch claim hides the contract from find_due until the claim expires on the database clock.
*/
- public function test_find_due_excludes_non_actionable_heads(): void {
+ public function test_a_live_claim_hides_the_contract_until_it_expires(): 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 );
- $billed = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED );
- $failed = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::FAILED );
- $processing = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PROCESSING );
+ $this->pin_db_clock( '2026-07-15 00:00:00' );
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
- $ids = $this->due_ids( $now, 50 );
+ $this->pin_db_clock( '2026-07-15 00:14:59' );
+ $this->assertSame( array(), $this->due_ids( $now, 50 ), 'A live claim hides the contract.' );
+ $this->assertSame( array(), $this->due_ids( $now->modify( '+1 day' ), 50 ), 'A later scan cutoff does not expire the claim; the database clock does.' );
- $this->assertContains( $billed, $ids );
- $this->assertNotContains( $failed, $ids, 'A failed head awaits dunning, not the scan.' );
- $this->assertNotContains( $processing, $ids, 'A processing head awaits its gateway, not the scan.' );
+ $this->pin_db_clock( '2026-07-15 00:15:00' );
+ $this->assertSame( array( $id ), $this->due_ids( $now, 50 ), 'An expired claim no longer hides it.' );
}
/**
- * @testdox find_due excludes a billed head whose period has not ended (the advance due-guard).
+ * @testdox claim_contract wins once on the database clock; a second claim on the same due moment loses.
*/
- public function test_find_due_excludes_a_billed_head_not_yet_ended(): void {
- $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ public function test_claim_contract_wins_once(): void {
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- // next_payment has arrived (the coarse index would pick it), but the head's own period
- // runs into the future - so the successor is not yet due and the scan must exclude it.
- $not_ended = $this->insert_contract_due_at( '2026-07-01 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, '2026-08-15 00:00:00' );
+ $this->pin_db_clock( '2035-01-01 00:00:00' );
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
+ $this->assertFalse( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ), 'An overlapping dispatcher loses.' );
+ $this->assertSame( '2035-01-01 00:15:00', $this->stored_claim( $id ), 'The claim is stamped from the database clock at the write.' );
- $this->assertNotContains( $not_ended, $this->due_ids( $now, 50 ) );
+ $this->pin_db_clock( '2035-01-01 00:15:00' );
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ), 'An expired claim can be taken again.' );
}
/**
- * @testdox find_due includes a reclaim-ready pending head but not a live-lease one.
+ * @testdox claim_contract loses when the due moment changed since the scan read it.
*/
- public function test_find_due_includes_reclaim_ready_pending_but_not_live_lease(): void {
- $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+ public function test_claim_contract_loses_when_the_due_moment_moved(): void {
+ $id = $this->insert_contract_due_at( '2026-06-20 00:00:00', ContractStatus::ACTIVE );
- $reclaimable = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING, '2026-07-14 00:00:00' );
- $live_lease = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING, '2026-08-15 00:00:00' );
+ $this->assertFalse( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
+ $this->assertNull( $this->stored_claim( $id ) );
+ }
- $ids = $this->due_ids( $now, 50 );
+ /**
+ * @testdox Moving or clearing next_payment_gmt releases the claim; re-saving it or other writes keep it.
+ */
+ public function test_due_moment_writes_release_the_claim(): void {
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $contract->set_status( ContractStatus::ON_HOLD );
+ $this->sut->update_fields( $contract, array( 'status' ) );
+ $this->assertNotNull( $this->stored_claim( $id ), 'A write that leaves the due moment alone keeps the claim.' );
- $this->assertContains( $reclaimable, $ids, 'A pending head whose lease has expired is reclaim-ready.' );
- $this->assertNotContains( $live_lease, $ids, 'A pending head with a live lease is a concurrent claim, not for the scan.' );
+ Contracts::update( $id, array( 'next_payment_gmt' => '2026-06-15 00:00:00' ) );
+ $this->assertNotNull( $this->stored_claim( $id ), 'Re-saving the same due moment keeps the claim (no queue jump).' );
+
+ $this->sut->update( $contract );
+ $this->assertNotNull( $this->stored_claim( $id ), 'A full update() with the same due moment keeps the claim.' );
+
+ $reformatted = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $reformatted );
+ $reformatted->set_next_payment_gmt( '2026-06-15T00:00:00' );
+ $this->sut->update_fields( $reformatted, array( 'next_payment_gmt' ) );
+ $this->assertNotNull( $this->stored_claim( $id ), 'The same moment in another DATETIME literal form keeps the claim (MySQL compares DATETIME values).' );
+ $stored = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $stored );
+ $this->assertSame( '2026-06-15 00:00:00', $stored->get_next_payment_gmt() );
+
+ Contracts::update( $id, array( 'next_payment_gmt' => '2026-06-16 00:00:00' ) );
+ $this->assertNull( $this->stored_claim( $id ), 'Moving the due moment releases the claim.' );
+
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-16 00:00:00', 900 ) );
+ Contracts::update( $id, array( 'next_payment_gmt' => null ) );
+ $this->assertNull( $this->stored_claim( $id ), 'Clearing the due moment releases the claim.' );
+
+ Contracts::update( $id, array( 'next_payment_gmt' => '2026-06-15 00:00:00' ) );
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
+ $moved = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $moved );
+ $moved->set_next_payment_gmt( '2026-07-01 00:00:00' );
+ $this->sut->update( $moved );
+ $this->assertNull( $this->stored_claim( $id ), 'A full update() that moves the due moment releases the claim.' );
+ $stored = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $stored );
+ $this->assertSame( '2026-07-01 00:00:00', $stored->get_next_payment_gmt() );
}
/**
- * @testdox find_due returns no rows for a non-positive limit.
+ * @testdox update_fields and update each write the contract row in exactly one UPDATE statement.
*/
- public function test_find_due_returns_no_rows_for_a_non_positive_limit(): 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 );
+ public function test_a_contract_write_is_one_update_statement(): void {
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $this->assertSame( array(), $this->sut->find_due( $now, 0, array( self::OWNER ) ) );
- $this->assertSame( array(), $this->sut->find_due( $now, -1, array( self::OWNER ) ) );
+ foreach ( array( 'update_fields', 'update' ) as $write ) {
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $on_hold = ContractStatus::ON_HOLD === $contract->get_status();
+ $contract->set_status( $on_hold ? ContractStatus::ACTIVE : ContractStatus::ON_HOLD );
+ $contract->set_next_payment_gmt( $on_hold ? '2026-06-15 00:00:00' : null );
+
+ $statements = $this->contract_updates_during(
+ function () use ( $write, $contract ): void {
+ if ( 'update_fields' === $write ) {
+ $this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) );
+ } else {
+ $this->sut->update( $contract );
+ }
+ }
+ );
+
+ $this->assertCount( 1, $statements, "{$write}() writes status and due moment in one UPDATE." );
+ $stored = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $stored );
+ $this->assertSame( $contract->get_status(), $stored->get_status() );
+ $this->assertSame( $contract->get_next_payment_gmt(), $stored->get_next_payment_gmt() );
+ }
}
/**
- * @testdox transition_cycle_status settles atomically: one caller wins, repeats lose.
+ * @testdox When the contract write is lost, neither the status nor the due moment nor the claim changes.
*/
- public function test_transition_cycle_status_is_an_atomic_compare_and_set(): void {
- $contract_id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::PENDING, '2026-07-14 00:00:00' );
+ public function test_a_lost_contract_write_changes_neither_status_nor_due_moment(): void {
+ $id = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+ $this->assertTrue( $this->sut->claim_contract( $id, '2026-06-15 00:00:00', 900 ) );
- $head = $this->sut->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
- $cycle_id = (int) $head->get_id();
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $contract->set_status( ContractStatus::ON_HOLD );
+ $contract->set_next_payment_gmt( null );
- // The winning settle: status flips, order stamped, lease cleared, reason NULL - one write.
- $this->assertTrue( $this->sut->transition_cycle_status( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, 4242 ) );
+ $drop = function ( string $query ): string {
+ return $this->is_contract_update( $query ) ? '' : $query;
+ };
- $settled = $this->sut->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $settled );
+ add_filter( 'query', $drop );
+ try {
+ $this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) );
+ $this->fail( 'Expected the lost write to throw.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertSame( 'Failed to update contract.', $e->getMessage() );
+ } finally {
+ remove_filter( 'query', $drop );
+ }
+
+ $stored = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $stored );
+ $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+ $this->assertSame( '2026-06-15 00:00:00', $stored->get_next_payment_gmt() );
+ $this->assertNotNull( $this->stored_claim( $id ) );
+ }
+
+ /**
+ * The UPDATE statements against the contracts table that `$run` issues, in order. Counts
+ * without dropping, so the count does not depend on how a write reacts to a lost statement.
+ *
+ * @param callable $run Code under test.
+ * @return array<int, string>
+ */
+ private function contract_updates_during( callable $run ): array {
+ $statements = array();
+ $count = function ( string $query ) use ( &$statements ): string {
+ if ( $this->is_contract_update( $query ) ) {
+ $statements[] = $query;
+ }
+ return $query;
+ };
+
+ add_filter( 'query', $count );
+ try {
+ $run();
+ } finally {
+ remove_filter( 'query', $count );
+ }
+
+ return $statements;
+ }
+
+ /**
+ * Whether a query is an UPDATE of the contracts table, with or without backtick quoting.
+ *
+ * @param string $query SQL statement.
+ */
+ private function is_contract_update( string $query ): bool {
+ $table = preg_quote( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), '/' );
+
+ return 1 === preg_match( "/^\\s*UPDATE\\s+`?{$table}`?\\s/i", $query );
+ }
+
+ /**
+ * @testdox An over-long payment method or title is refused by insert, update_fields and update alike, and nothing is stored.
+ * @dataProvider provide_over_long_payment_instruments
+ *
+ * @param string $method Payment method id.
+ * @param string $title Payment method title.
+ */
+ public function test_an_over_long_payment_instrument_is_refused_on_insert_and_update( string $method, string $title ): void {
+ $over_long = new PaymentInstrumentRef( 55, $method, $title );
+
+ $fresh = $this->make_contract();
+ $fresh->set_payment_instrument( $over_long );
+ try {
+ $this->sut->insert( $fresh );
+ $this->fail( 'Expected insert() to refuse the over-long value.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertNull( $fresh->get_id(), 'Nothing is inserted.' );
+ }
+
+ $id = $this->sut->insert( $this->make_contract() );
+ foreach ( array( 'update_fields', 'update' ) as $write ) {
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $contract->set_payment_instrument( $over_long );
+ $contract->set_status( ContractStatus::ON_HOLD );
+
+ try {
+ if ( 'update_fields' === $write ) {
+ $this->sut->update_fields( $contract, array( 'status', 'payment_method', 'payment_method_title' ) );
+ } else {
+ $this->sut->update( $contract );
+ }
+ $this->fail( "Expected {$write}() to refuse the over-long value." );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
+ }
+
+ $stored = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $stored );
+ $this->assertSame( 'woocommerce_payments', $stored->get_payment_instrument()->get_gateway(), "{$write}() wrote nothing." );
+ $this->assertSame( 'Credit card', $stored->get_payment_instrument()->get_title() );
+ $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+ }
+
+ $fits = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $fits );
+ $fits->set_payment_instrument( new PaymentInstrumentRef( 55, str_repeat( 'm', 100 ), str_repeat( 'é', 200 ) ) );
+ $this->sut->update_fields( $fits, array( 'payment_method', 'payment_method_title' ) );
+ $written = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $written );
+ $this->assertSame( str_repeat( 'é', 200 ), $written->get_payment_instrument()->get_title(), 'A value at the column length (in characters) is written.' );
+ }
+
+ /**
+ * @testdox An over-long cycle kind is refused by append_cycle and nothing is stored; one at the column length is written.
+ */
+ public function test_an_over_long_cycle_kind_is_refused_on_append(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $over_long = $this->make_cycle_of_kind( $id, str_repeat( 'k', 21 ) );
+ try {
+ $this->sut->append_cycle( $over_long );
+ $this->fail( 'Expected append_cycle() to refuse the over-long kind.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'longer than the column allows', $e->getMessage() );
+ }
+ $this->assertNull( $over_long->get_id(), 'Nothing is inserted.' );
+ $this->assertSame( array(), $this->sut->find_cycle_history( $id, str_repeat( 'k', 21 ) ) );
+
+ $fits = $this->make_cycle_of_kind( $id, str_repeat( 'k', 20 ) );
+ $this->sut->append_cycle( $fits );
+ $this->assertNotNull( $fits->get_id(), 'A kind at the column length is written.' );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: string}>
+ */
+ public function provide_over_long_payment_instruments(): array {
+ return array(
+ 'payment method over 100' => array( str_repeat( 'm', 101 ), 'Credit card' ),
+ 'payment method title over 200' => array( 'woocommerce_payments', str_repeat( 't', 201 ) ),
+ );
+ }
+
+ /**
+ * The stored due-dispatch claim of a contract.
+ *
+ * @param int $id Contract id.
+ */
+ private function stored_claim( int $id ): ?string {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $claim = $wpdb->get_var( $wpdb->prepare( "SELECT claimed_until FROM {$table} WHERE id = %d", $id ) );
+
+ return null === $claim ? null : (string) $claim;
+ }
+
+ /**
+ * @testdox transition_cycle is a compare-and-set: one caller wins, a caller that read the old status loses.
+ */
+ public function test_transition_cycle_is_an_atomic_compare_and_set(): void {
+ $cycle_id = $this->claimd_pending_cycle_id();
+
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::BILLED, array( 'order_id' => 4242 ) ) );
+
+ $settled = $this->find_cycle( $cycle_id );
$this->assertSame( CycleStatus::BILLED, $settled->get_status()->get_value() );
$this->assertSame( 4242, $settled->get_order_id() );
- $this->assertNull( $settled->get_claimed_until_gmt() );
- $this->assertNull( $settled->get_reason() );
+ $this->assertNull( $settled->get_claimed_until_gmt(), 'Without claim_seconds the claim is cleared.' );
- // A racing settler that read the old status matches zero rows and loses.
- $this->assertFalse( $this->sut->transition_cycle_status( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, 9999, 'gateway-charge-failed' ) );
+ $this->assertFalse( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'order_id' => 9999 ) ) );
- $after = $this->sut->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $after );
+ $after = $this->find_cycle( $cycle_id );
$this->assertSame( CycleStatus::BILLED, $after->get_status()->get_value(), 'The losing transition writes nothing.' );
$this->assertSame( 4242, $after->get_order_id() );
}
/**
- * @testdox transition_cycle_status rejects an unregistered target status and writes nothing.
+ * @testdox A same-status transition_cycle that changes nothing still returns true; on a cycle in another status it returns false.
*/
- 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 );
+ public function test_a_same_status_transition_that_changes_nothing_returns_true(): void {
+ $cycle_id = $this->claimd_pending_cycle_id();
- $head = $this->sut->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $head );
+ // Start early in a second so both calls stamp the same date_updated_gmt and the
+ // second one matches the row without changing it.
+ while ( fmod( microtime( true ), 1.0 ) > 0.5 ) {
+ usleep( 50000 );
+ }
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) ) );
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) ), 'Writing the stored values again still succeeds.' );
+
+ $this->assertFalse( $this->sut->transition_cycle( $cycle_id, CycleStatus::BILLED, CycleStatus::BILLED, array( 'order_id' => 4242 ) ), 'A cycle in another status is not matched.' );
+ $this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
+ }
+
+ /**
+ * @testdox transition_cycle keeps the stored order when order_id is omitted and clears it on null.
+ */
+ public function test_transition_cycle_order_id_is_written_only_when_present(): void {
+ $cycle_id = $this->claimd_pending_cycle_id();
+ $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::PENDING, array( 'order_id' => 4242 ) );
+
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array( 'reason' => 'payment-failed' ) ) );
+ $failed = $this->find_cycle( $cycle_id );
+ $this->assertSame( 4242, $failed->get_order_id(), 'An omitted order_id keeps the stored one.' );
+ $this->assertSame( 'payment-failed', $failed->get_reason() );
+
+ $this->assertTrue(
+ $this->sut->transition_cycle(
+ $cycle_id,
+ CycleStatus::FAILED,
+ CycleStatus::CANCELLED,
+ array(
+ 'order_id' => null,
+ 'reason' => null,
+ )
+ )
+ );
+ $cancelled = $this->find_cycle( $cycle_id );
+ $this->assertNull( $cancelled->get_order_id(), 'A null order_id clears it.' );
+ $this->assertNull( $cancelled->get_reason(), 'A null reason clears it.' );
+ }
+
+ /**
+ * @testdox transition_cycle sets a claim from claim_seconds, e.g. retrying a failed cycle.
+ */
+ public function test_transition_cycle_sets_a_claim(): void {
+ $cycle_id = $this->claimd_pending_cycle_id();
+ $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, CycleStatus::FAILED, array() );
+
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::FAILED, CycleStatus::PENDING, array( 'claim_seconds' => 900 ) ) );
+
+ $retried = $this->find_cycle( $cycle_id );
+ $this->assertSame( CycleStatus::PENDING, $retried->get_status()->get_value() );
+ $this->assertNotNull( $retried->get_claimed_until_gmt() );
+ $this->assertGreaterThan( time() + 800, strtotime( $retried->get_claimed_until_gmt() . ' UTC' ) );
+ $this->assertFalse( $this->sut->claim_cycle( $cycle_id, CycleStatus::PENDING, 900 ), 'The retrier holds the claim.' );
+ }
+
+ /**
+ * @testdox transition_cycle rejects an unregistered target status and writes nothing.
+ */
+ public function test_transition_cycle_rejects_an_unregistered_target(): void {
+ $cycle_id = $this->claimd_pending_cycle_id();
try {
- $this->sut->transition_cycle_status( (int) $head->get_id(), CycleStatus::PENDING, 'never-registered', 4242 );
+ $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, 'never-registered', array( 'order_id' => 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() );
+ $this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
}
}
/**
- * @testdox transition_cycle_status accepts an extension-registered target status.
+ * @testdox transition_cycle accepts an extension-registered target status.
*/
- public function test_transition_cycle_status_accepts_an_extension_registered_target(): void {
+ public function test_transition_cycle_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 );
+ $cycle_id = $this->claimd_pending_cycle_id();
+
+ $this->assertTrue( $this->sut->transition_cycle( $cycle_id, CycleStatus::PENDING, 'disputed', array() ) );
+
+ $this->assertSame( 'disputed', $this->find_cycle( $cycle_id )->get_status()->get_value() );
+ }
+
+ /**
+ * @testdox A billed cycle hydrates its plan and items snapshots.
+ */
+ public function test_a_billed_cycle_hydrates_its_snapshots(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+ $snapshot_ids = $this->seed_cycle_snapshots( $id );
+ $cycle = Cycle::create(
+ array(
+ 'contract_id' => $id,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'status' => new CycleStatus( CycleStatus::BILLED ),
+ 'starts_at_gmt' => '2026-07-15 00:00:00',
+ 'ends_at_gmt' => '2026-08-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'plan_snapshot_id' => $snapshot_ids[0],
+ 'items_snapshot_id' => $snapshot_ids[1],
+ )
+ );
+ $this->sut->append_cycle( $cycle );
- $head = $this->sut->find_chain_head( $contract_id );
+ $head = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $head );
+ $this->assertInstanceOf( PlanSnapshot::class, $head->get_plan_snapshot() );
+ $this->assertInstanceOf( ItemsSnapshot::class, $head->get_items_snapshot() );
+ }
+
+ /**
+ * A pending cycle 1 with an expired claim on a new contract.
+ */
+ private function claimd_pending_cycle_id(): int {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ return (int) $this->append_pending_cycle_with_claim( $id, gmdate( 'Y-m-d H:i:s', time() - 60 ) )->get_id();
+ }
+
+ /**
+ * Read a billing cycle by id through its chain history.
+ *
+ * @param int $cycle_id Cycle id.
+ */
+ private function find_cycle( int $cycle_id ): Cycle {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $contract_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT contract_id FROM {$table} WHERE id = %d", $cycle_id ) );
- $this->assertTrue( $this->sut->transition_cycle_status( (int) $head->get_id(), CycleStatus::PENDING, 'disputed', 4242 ) );
+ foreach ( $this->sut->find_cycle_history( $contract_id ) as $cycle ) {
+ if ( $cycle->get_id() === $cycle_id ) {
+ return $cycle;
+ }
+ }
- $after = $this->sut->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $after );
- $this->assertSame( 'disputed', $after->get_status()->get_value() );
+ $this->fail( sprintf( 'Cycle %d not found.', $cycle_id ) );
}
/**
@@ -1437,7 +1833,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$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 );
@@ -1446,13 +1841,13 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
}
/**
- * @testdox An unknown stored cycle status hydrates through every cycle read and survives an update.
+ * @testdox An unknown stored cycle status hydrates through every cycle read.
*/
- public function test_an_unknown_stored_cycle_status_hydrates_and_round_trips(): void {
+ public function test_an_unknown_stored_cycle_status_hydrates_through_every_cycle_read(): 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() );
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) );
$this->sut->append_cycle( $cycle );
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
@@ -1466,14 +1861,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$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'] );
}
/**
@@ -1499,8 +1886,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
*/
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();
+ static function ( array $row ): int {
+ return $row['id'];
},
$this->sut->find_due( $now, $limit, $owners ?? array( self::OWNER ) )
);
@@ -1513,16 +1900,16 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
*
* @param string|null $next_payment_gmt The next-payment date, or null for no schedule/head.
* @param string $status The contract status (a ContractStatus value).
- * @param string $schedule_source Who owns the schedule (primitive or gateway).
+ * @param string $schedule_source Who keeps the schedule (the engine, or a gateway/extension slug).
* @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 $claimed_until The head cycle claim expiry, or null for none.
* @param string|null $head_ends_at The head period end; defaults to `$next_payment_gmt`.
* @param string $owner The owning extension slug; defaults to the registered test owner.
*/
private function insert_contract_due_at(
?string $next_payment_gmt,
string $status,
- string $schedule_source = Contract::SCHEDULE_SOURCE_PRIMITIVE,
+ string $schedule_source = Contract::SCHEDULE_SOURCE_ENGINE,
string $head_status = CycleStatus::BILLED,
?string $claimed_until = null,
?string $head_ends_at = null,
@@ -1555,71 +1942,19 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'ends_at_gmt' => $head_ends_at ?? $next_payment_gmt,
'expected_total' => '19.99',
'currency' => 'USD',
- 'claimed_until' => $claimed_until,
)
)
);
+ if ( null !== $claimed_until ) {
+ $head = $this->sut->find_chain_head( $id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->stamp_cycle_claim( (int) $head->get_id(), $claimed_until );
+ }
}
return $id;
}
- /**
- * @testdox Consecutive cycles with an unchanged plan/items share one snapshot row each.
- */
- public function test_copy_forward_reuses_unchanged_snapshots(): void {
- global $wpdb;
-
- $id = $this->sut->insert( $this->make_contract() );
-
- $first = $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( $first );
-
- // The next cycle's plan/items are unchanged: copy-forward should reuse the ids.
- $second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
- $this->sut->append_cycle( $second, $first );
-
- $this->assertSame( $first->get_plan_snapshot_id(), $second->get_plan_snapshot_id() );
- $this->assertSame( $first->get_items_snapshot_id(), $second->get_items_snapshot_id() );
-
- // Exactly two snapshot rows: one plan payload, one items payload.
- $snapshots = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $row_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$snapshots} WHERE contract_id = %d", $id ) );
- $this->assertSame( 2, $row_count );
- }
-
- /**
- * @testdox A changed plan snapshot inserts a new row instead of copy-forwarding.
- */
- public function test_copy_forward_inserts_a_new_row_when_the_plan_changes(): void {
- global $wpdb;
-
- $id = $this->sut->insert( $this->make_contract() );
-
- $first = $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( $first );
-
- // The plan terms changed, so the plan snapshot must not be reused.
- $changed_plan = PlanSnapshot::from_array(
- array(
- 'selling_plan_id' => 7,
- 'cadence' => 'weekly',
- )
- );
- $second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', $changed_plan, $this->sample_items_snapshot() );
- $this->sut->append_cycle( $second, $first );
-
- $this->assertNotSame( $first->get_plan_snapshot_id(), $second->get_plan_snapshot_id() );
- // The items were unchanged, so that row is still shared.
- $this->assertSame( $first->get_items_snapshot_id(), $second->get_items_snapshot_id() );
-
- $snapshots = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $row_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$snapshots} WHERE contract_id = %d", $id ) );
- $this->assertSame( 3, $row_count, 'Two plan payloads plus one shared items payload.' );
- }
-
/**
* @testdox A duplicate (contract_id, kind, sequence_no) is rejected by the UNIQUE index.
*/
@@ -1779,7 +2114,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
global $wpdb;
$id = $this->sut->insert( $this->make_contract() );
- $this->sut->append_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( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', ...$this->seed_cycle_snapshots( $id ) ) );
$this->assertTrue( $this->sut->delete( $id ) );
$this->assertNull( $this->sut->find( $id ) );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
index 2ac69c8ffd2..655a14cc634 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
@@ -10,6 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
@@ -290,10 +291,42 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
}
/**
- * @testdox The schema version is 2.6.0 (plans as records, plan meta table).
+ * @testdox The schema version is 2.7.0 (due dispatch).
*/
- public function test_schema_version_is_2_6_0(): void {
- $this->assertSame( '2.6.0', SchemaInstaller::get_version() );
+ public function test_schema_version_is_2_7_0(): void {
+ $this->assertSame( '2.7.0', SchemaInstaller::get_version() );
+ }
+
+ /**
+ * @testdox Contracts default schedule_source to the engine's own schedule, in a column wide enough for extension slugs.
+ */
+ public function test_contracts_schedule_source_defaults_to_the_engine(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'schedule_source' ), ARRAY_A );
+
+ $this->assertIsArray( $row, 'Expected a contracts.schedule_source column.' );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_ENGINE, $row['Default'] ?? null );
+ $this->assertSame( 'varchar(64)', $row['Type'] ?? null );
+ }
+
+ /**
+ * @testdox Contracts carry a nullable claimed_until column for the due-dispatch claim.
+ */
+ public function test_contracts_claimed_until_is_nullable(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'claimed_until' ), ARRAY_A );
+
+ $this->assertIsArray( $row, 'Expected a contracts.claimed_until column.' );
+ $this->assertSame( 'YES', $row['Null'] ?? null, 'Expected contracts.claimed_until to be NULLable.' );
+ $this->assertSame( 'datetime', $row['Type'] ?? null );
}
/**
@@ -359,7 +392,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
}
/**
- * @testdox The cycle dispatcher columns (claimed_until lease, reserved retry_at) are nullable.
+ * @testdox The cycle dispatcher columns (claimed_until claim, reserved retry_at) are nullable.
*/
public function test_cycles_table_dispatcher_columns_are_nullable(): void {
global $wpdb;
@@ -395,11 +428,45 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
$this->assertContains( 'chain_seq', $indexes, 'Expected the UNIQUE (contract_id, kind, sequence_no) index.' );
$this->assertContains( 'chain_count', $indexes, 'Expected the UNIQUE (contract_id, kind, count) index.' );
- $this->assertContains( 'due', $indexes, 'Expected the due (kind, status, starts_at_gmt) index.' );
$this->assertContains( 'order_id', $indexes, 'Expected the (order_id) index.' );
$this->assertContains( 'contract_kind', $indexes, 'Expected the (contract_id, kind) index.' );
}
+ /**
+ * @testdox The installer declares no cycles due index (the due dispatcher scans contracts).
+ *
+ * Reads the schema the installer hands to dbDelta rather than the live table: dbDelta never
+ * drops an index, so a long-lived test database may still carry one from an earlier schema.
+ */
+ public function test_cycles_schema_declares_no_due_index(): void {
+ $queries = array();
+ $capture = static function ( array $dbdelta_queries ) use ( &$queries ): array {
+ $queries = array_merge( $queries, $dbdelta_queries );
+
+ // Run no DDL: it would implicitly commit the test transaction.
+ return array();
+ };
+
+ add_filter( 'dbdelta_queries', $capture );
+ try {
+ SchemaInstaller::install();
+ } finally {
+ remove_filter( 'dbdelta_queries', $capture );
+ }
+
+ $cycles = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $create = '';
+ foreach ( $queries as $query ) {
+ if ( is_string( $query ) && false !== strpos( $query, "CREATE TABLE {$cycles} (" ) ) {
+ $create = $query;
+ }
+ }
+
+ $this->assertNotSame( '', $create, 'Expected the installer to declare the cycles table.' );
+ $this->assertStringContainsString( 'KEY contract_kind (contract_id, kind)', $create );
+ $this->assertDoesNotMatchRegularExpression( '/\bKEY due\b/', $create );
+ }
+
public function test_cycles_chain_sequence_index_columns_are_in_order(): void {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
@@ -418,17 +485,6 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
);
}
- public function test_cycles_due_index_columns_are_in_scan_order(): void {
- $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
-
- // The dispatcher scans the due index as (kind, status, starts_at_gmt); the
- // column order is load-bearing, so assert it exactly, not just existence.
- $this->assertSame(
- array( 'kind', 'status', 'starts_at_gmt' ),
- $this->index_columns( $table, 'due' )
- );
- }
-
public function test_cycles_contract_kind_index_columns_are_in_order(): void {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
index 9671edfa1bc..8b9965b8950 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
@@ -48,6 +48,8 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
'money' => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
'money null is zero' => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
+ 'positive int' => array( 'validate_positive_int', array( 'claim_seconds', 900 ), 900 ),
+ 'positive int digits' => array( 'validate_positive_int', array( 'claim_seconds', '60' ), 60 ),
'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
'non-negative digits' => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
'id list' => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
@@ -86,6 +88,8 @@ class ArgumentValidatorTest extends EngineIntegrationTestCase {
'money' => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
'negative int' => array( 'validate_non_negative_int', array( 'offset', -1 ), '"offset" must be a non-negative integer.' ),
+ 'zero positive' => array( 'validate_positive_int', array( 'claim_seconds', 0 ), '"claim_seconds" must be a positive integer.' ),
+ 'null positive' => array( 'validate_positive_int', array( 'claim_seconds', null ), '"claim_seconds" must be a positive integer.' ),
'id list entry' => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), '"ids" must be a list of positive integers.' ),
'id list null' => array( 'validate_id_list', array( 'ids', array( null ) ), '"ids" must be a list of positive integers.' ),
'id list map' => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), '"ids" must be a list of positive integers.' ),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
index 341a9744bda..37d7c0cb246 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
@@ -45,7 +45,7 @@ class ContractViewTest extends TestCase {
'discount_total' => '1.00',
'shipping_total' => '5.00',
'tax_total' => '2.50',
- 'schedule_source' => Contract::SCHEDULE_SOURCE_GATEWAY,
+ 'schedule_source' => 'my_gateway',
);
}
@@ -72,7 +72,7 @@ class ContractViewTest extends TestCase {
$this->assertSame( '1.00000000', $view->get_discount_total() );
$this->assertSame( '5.00000000', $view->get_shipping_total() );
$this->assertSame( '2.50000000', $view->get_tax_total() );
- $this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $view->get_schedule_source() );
+ $this->assertSame( 'my_gateway', $view->get_schedule_source() );
}
public function test_optional_fields_read_as_null(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
index f4279620fd9..057c9fefd71 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
@@ -32,6 +32,7 @@ class CycleViewTest extends TestCase {
'expected_total' => '20.00',
'currency' => 'USD',
'order_id' => 77,
+ 'reason' => 'payment-failed',
)
);
@@ -48,6 +49,7 @@ class CycleViewTest extends TestCase {
$this->assertSame( '20.00000000', $view->get_expected_total() );
$this->assertSame( 'USD', $view->get_currency() );
$this->assertSame( 77, $view->get_order_id() );
+ $this->assertSame( 'payment-failed', $view->get_reason() );
}
public function test_a_non_counting_cycle_without_an_order_reads_null(): void {
@@ -68,6 +70,7 @@ class CycleViewTest extends TestCase {
$this->assertNull( $view->get_count() );
$this->assertNull( $view->get_order_id() );
+ $this->assertNull( $view->get_reason() );
$this->assertSame( 'pending', $view->get_status() );
}
}
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 5c86b44d8d3..857b6f720a2 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
@@ -59,7 +59,7 @@ class ContractTest extends TestCase {
'last_attempt_gmt' => '2026-01-01 00:00:00',
'trial_end_gmt' => null,
'end_gmt' => null,
- 'schedule_source' => Contract::SCHEDULE_SOURCE_PRIMITIVE,
+ 'schedule_source' => Contract::SCHEDULE_SOURCE_ENGINE,
);
}
@@ -183,7 +183,7 @@ class ContractTest extends TestCase {
$contract->set_selling_plan_id( 8 );
$contract->set_origin_order_id( 9 );
$contract->set_start_gmt( '2026-03-01 00:00:00' );
- $contract->set_schedule_source( Contract::SCHEDULE_SOURCE_GATEWAY );
+ $contract->set_schedule_source( 'my_gateway' );
$contract->set_items( array( array( 'item_name' => 'Coffee' ), 'skipped' ) );
$contract->set_addresses( array( Contract::ADDRESS_BILLING => array( 'city' => 'Lisbon' ) ) );
@@ -192,7 +192,7 @@ class ContractTest extends TestCase {
$this->assertSame( 8, $contract->get_selling_plan_id() );
$this->assertSame( 9, $contract->get_origin_order_id() );
$this->assertSame( '2026-03-01 00:00:00', $contract->get_start_gmt() );
- $this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $contract->get_schedule_source() );
+ $this->assertSame( 'my_gateway', $contract->get_schedule_source() );
$this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $contract->get_items() );
$this->assertSame( array( 'billing' => array( 'city' => 'Lisbon' ) ), $contract->get_addresses() );
@@ -210,15 +210,48 @@ class ContractTest extends TestCase {
}
/**
- * @testdox set_schedule_source() refuses an unknown source.
+ * @testdox set_schedule_source() refuses a source that is not a valid slug.
+ * @dataProvider provide_invalid_schedule_sources
+ *
+ * @param string $schedule_source Invalid schedule source.
*/
- public function test_set_schedule_source_rejects_an_unknown_source(): void {
+ public function test_set_schedule_source_rejects_an_invalid_slug( string $schedule_source ): void {
$contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
$this->expectException( DomainException::class );
- $this->expectExceptionMessage( 'Contract: invalid schedule source "bogus".' );
+ $this->expectExceptionMessage( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ) );
+
+ $contract->set_schedule_source( $schedule_source );
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_invalid_schedule_sources(): array {
+ return array(
+ 'empty' => array( '' ),
+ 'uppercase' => array( 'MyGateway' ),
+ 'space' => array( 'my gateway' ),
+ 'dot' => array( 'my.gateway' ),
+ 'markup' => array( '<b>' ),
+ 'over 64 chars' => array( str_repeat( 'a', 65 ) ),
+ 'trailing LF' => array( "my_gateway\n" ),
+ );
+ }
+
+ /**
+ * @testdox The schedule source defaults to the engine and accepts any slug of 1-64 lowercase letters, digits, underscores or hyphens.
+ */
+ public function test_set_schedule_source_accepts_an_open_slug(): void {
+ $contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+ $this->assertSame( Contract::SCHEDULE_SOURCE_ENGINE, $contract->get_schedule_source() );
- $contract->set_schedule_source( 'bogus' );
+ foreach ( array( 'a', 'my-gateway_2', 'woocommerce-subscriptions-lite', str_repeat( 'a', 64 ), Contract::SCHEDULE_SOURCE_ENGINE ) as $schedule_source ) {
+ $contract->set_schedule_source( $schedule_source );
+
+ $this->assertSame( $schedule_source, $contract->get_schedule_source() );
+ }
}
/**
@@ -236,7 +269,7 @@ class ContractTest extends TestCase {
$this->assertSame( 'acme-subs', $contract->get_extension_slug() );
$this->assertSame( '2026-01-01 00:00:00', $contract->get_start_gmt() );
$this->assertSame( '2026-02-01 00:00:00', $contract->get_next_payment_gmt() );
- $this->assertSame( Contract::SCHEDULE_SOURCE_PRIMITIVE, $contract->get_schedule_source() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_ENGINE, $contract->get_schedule_source() );
}
/**
@@ -342,7 +375,7 @@ class ContractTest extends TestCase {
'selling_plan_id' => 2,
'origin_order_id' => 3,
'start_gmt' => '2026-01-01 00:00:00',
- 'schedule_source' => 'nonsense',
+ 'schedule_source' => 'Not a slug',
)
);
}
@@ -440,7 +473,7 @@ class ContractTest extends TestCase {
$this->assertSame( '2026-01-01 00:00:00', $contract->get_last_attempt_gmt() );
$this->assertNull( $contract->get_trial_end_gmt() );
$this->assertNull( $contract->get_end_gmt() );
- $this->assertSame( Contract::SCHEDULE_SOURCE_PRIMITIVE, $contract->get_schedule_source() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_ENGINE, $contract->get_schedule_source() );
}
/**
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 95d28f19b7d..a5878a1fd91 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
@@ -46,24 +46,6 @@ class CycleStatusTest extends TestCase {
$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 ) );
- }
-
- public function test_pending_processing_and_failed_are_not_terminal(): void {
- $this->assertFalse( CycleStatus::is_terminal( CycleStatus::PENDING ) );
- $this->assertFalse( CycleStatus::is_terminal( CycleStatus::PROCESSING ) );
- $this->assertFalse( CycleStatus::is_terminal( CycleStatus::FAILED ) );
- }
-
- public function test_unknown_and_extension_registered_statuses_are_not_terminal(): void {
- StatusRegistry::register( StatusRegistry::KIND_CYCLE, 'disputed' );
-
- $this->assertFalse( CycleStatus::is_terminal( 'disputed' ) );
- $this->assertFalse( CycleStatus::is_terminal( 'legacy-x' ) );
- }
-
public function test_constructor_wraps_a_default_status(): void {
$this->assertSame( CycleStatus::PENDING, ( new CycleStatus( CycleStatus::PENDING ) )->get_value() );
}
@@ -126,11 +108,6 @@ class CycleStatusTest extends TestCase {
$this->assertTrue( CycleStatus::is_registered( 'disputed' ) );
}
- public function test_equals_compares_by_value(): void {
- $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_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 517f2c83e06..6822273a816 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
@@ -58,7 +58,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( new CycleStatus( CycleStatus::PENDING ) ) );
+ $this->assertSame( CycleStatus::PENDING, $cycle->get_status()->get_value() );
$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() );
@@ -83,7 +83,7 @@ class CycleTest extends TestCase {
// The checkout signup cycle is created directly billed (the origin order is paid).
$cycle = $this->make_pending( array( 'status' => new CycleStatus( CycleStatus::BILLED ) ) );
- $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
+ $this->assertSame( CycleStatus::BILLED, $cycle->get_status()->get_value() );
}
public function test_create_requires_a_contract_id(): void {
@@ -172,34 +172,23 @@ class CycleTest extends TestCase {
$this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
}
- public function test_holds_snapshot_vos_when_provided(): void {
- $plan = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
- $items = ItemsSnapshot::from_items( array( array( 'product_id' => 42 ) ) );
+ public function test_create_ignores_a_claim_expiry(): void {
+ $cycle = $this->make_pending( array( 'claimed_until' => '2026-03-01 00:15:00' ) );
+
+ $this->assertNull( $cycle->get_claimed_until_gmt(), 'The database stamps a claim on append; a new cycle carries none.' );
+ $this->assertNull( $cycle->to_storage()['claimed_until'] );
+ }
+ public function test_create_ignores_snapshot_value_objects(): void {
$cycle = $this->make_pending(
array(
- 'plan_snapshot' => $plan,
- 'items_snapshot' => $items,
+ 'plan_snapshot' => PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) ),
+ 'items_snapshot' => ItemsSnapshot::from_items( array( array( 'product_id' => 42 ) ) ),
)
);
- $this->assertSame( $plan, $cycle->get_plan_snapshot() );
- $this->assertSame( $items, $cycle->get_items_snapshot() );
- }
-
- public function test_status_changes_go_through_cycle_status(): void {
- $cycle = $this->make_pending();
-
- $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 ) ) );
+ $this->assertNull( $cycle->get_plan_snapshot(), 'Snapshot value objects are attached only on read.' );
+ $this->assertNull( $cycle->get_items_snapshot() );
}
public function test_create_accepts_an_extension_registered_status_string(): void {
@@ -230,55 +219,12 @@ class CycleTest extends TestCase {
$this->assertSame( 'legacy-x', $cycle->to_storage()['status'] );
}
- 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() );
- }
-
public function test_create_rejects_an_unregistered_status_instance(): void {
$this->expectException( DomainException::class );
$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( new CycleStatus( CycleStatus::PENDING ) );
-
- $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::PENDING ) ) );
- }
-
public function test_period_boundaries_are_frozen_at_construction(): void {
// The Cycle is an immutable record: there is no setter for the period
// boundaries, they are fixed by create()/from_storage(). Asserting the
@@ -289,32 +235,6 @@ class CycleTest extends TestCase {
$this->assertFalse( $reflection->hasMethod( 'set_ends_at_gmt' ) );
}
- public function test_plan_snapshot_id_is_stamped_write_once(): void {
- $cycle = $this->make_pending();
-
- $cycle->set_plan_snapshot_id( 11 );
- $cycle->set_items_snapshot_id( 22 );
-
- $this->assertSame( 11, $cycle->get_plan_snapshot_id() );
- $this->assertSame( 22, $cycle->get_items_snapshot_id() );
- }
-
- public function test_plan_snapshot_id_cannot_be_re_pointed(): void {
- $cycle = $this->make_pending();
- $cycle->set_plan_snapshot_id( 11 );
-
- $this->expectException( DomainException::class );
- $cycle->set_plan_snapshot_id( 99 );
- }
-
- public function test_items_snapshot_id_cannot_be_re_pointed(): void {
- $cycle = $this->make_pending();
- $cycle->set_items_snapshot_id( 22 );
-
- $this->expectException( DomainException::class );
- $cycle->set_items_snapshot_id( 99 );
- }
-
public function test_plan_snapshot_vo_is_attached_write_once(): void {
$cycle = $this->make_pending();
@@ -325,54 +245,21 @@ class CycleTest extends TestCase {
}
public function test_plan_snapshot_vo_cannot_be_replaced(): void {
- $cycle = $this->make_pending( array( 'plan_snapshot' => PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) ) ) );
+ $cycle = $this->make_pending();
+ $cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) ) );
$this->expectException( DomainException::class );
$cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
}
public function test_items_snapshot_vo_cannot_be_replaced(): void {
- $cycle = $this->make_pending( array( 'items_snapshot' => ItemsSnapshot::from_items( array() ) ) );
+ $cycle = $this->make_pending();
+ $cycle->set_items_snapshot( ItemsSnapshot::from_items( array() ) );
$this->expectException( DomainException::class );
$cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
}
- public function test_reason_can_be_annotated_on_a_pending_cycle(): void {
- $cycle = $this->make_pending();
-
- $cycle->set_reason( 'flagged for review' );
-
- $this->assertSame( 'flagged for review', $cycle->get_reason() );
- }
-
- public function test_reason_can_be_annotated_when_cancelling_a_pending_cycle(): void {
- $cycle = $this->make_pending();
-
- $cycle->set_status( new CycleStatus( CycleStatus::CANCELLED ) );
- $cycle->set_reason( 'customer requested cancellation' );
-
- $this->assertSame( 'customer requested cancellation', $cycle->get_reason() );
- }
-
- 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' => new CycleStatus( CycleStatus::BILLED ) ) );
-
- $cycle->set_reason( 'settled after retry' );
-
- $this->assertSame( 'settled after retry', $cycle->get_reason() );
- }
-
- public function test_reason_can_be_annotated_on_a_failed_cycle(): void {
- $cycle = $this->make_pending();
- $cycle->set_status( new CycleStatus( CycleStatus::FAILED ) );
-
- $cycle->set_reason( 'gateway declined the charge' );
-
- $this->assertSame( 'gateway declined the charge', $cycle->get_reason() );
- }
-
public function test_from_storage_hydrates_a_persisted_cycle(): void {
$cycle = Cycle::from_storage(
array(
@@ -398,65 +285,13 @@ 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( new CycleStatus( CycleStatus::BILLED ) ) );
+ $this->assertSame( CycleStatus::BILLED, $cycle->get_status()->get_value() );
$this->assertSame( 11, $cycle->get_plan_snapshot_id() );
$this->assertSame( 22, $cycle->get_items_snapshot_id() );
$this->assertSame( 123, $cycle->get_order_id() );
$this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
}
- public function test_from_storage_cycle_plan_snapshot_ref_is_write_once(): void {
- // A cycle hydrated from storage already carries non-null snapshot refs through
- // the constructor; the write-once guard must still reject re-pointing them.
- $cycle = Cycle::from_storage(
- array(
- 'id' => 5,
- 'contract_id' => 7,
- 'sequence_no' => 2,
- 'count' => 2,
- 'kind' => Cycle::KIND_BILLING,
- 'status' => 'billed',
- 'reason' => null,
- 'starts_at_gmt' => '2026-03-01 00:00:00',
- 'ends_at_gmt' => '2026-04-01 00:00:00',
- 'expected_total' => '20.00',
- 'currency' => 'USD',
- 'plan_snapshot_id' => 11,
- 'items_snapshot_id' => 22,
- 'order_id' => 123,
- 'extension_slug' => 'acme-subs',
- )
- );
-
- $this->expectException( DomainException::class );
- $cycle->set_plan_snapshot_id( 99 );
- }
-
- public function test_from_storage_cycle_items_snapshot_ref_is_write_once(): void {
- $cycle = Cycle::from_storage(
- array(
- 'id' => 5,
- 'contract_id' => 7,
- 'sequence_no' => 2,
- 'count' => 2,
- 'kind' => Cycle::KIND_BILLING,
- 'status' => 'billed',
- 'reason' => null,
- 'starts_at_gmt' => '2026-03-01 00:00:00',
- 'ends_at_gmt' => '2026-04-01 00:00:00',
- 'expected_total' => '20.00',
- 'currency' => 'USD',
- 'plan_snapshot_id' => 11,
- 'items_snapshot_id' => 22,
- 'order_id' => 123,
- 'extension_slug' => 'acme-subs',
- )
- );
-
- $this->expectException( DomainException::class );
- $cycle->set_items_snapshot_id( 99 );
- }
-
public function test_from_storage_keeps_stored_values_that_create_would_refuse(): void {
$cycle = Cycle::from_storage(
array(
@@ -502,12 +337,12 @@ class CycleTest extends TestCase {
public function test_to_storage_excludes_id_and_serializes_the_row(): void {
$cycle = $this->make_pending(
array(
- 'order_id' => 123,
- 'extension_slug' => 'acme-subs',
- 'plan_snapshot_id' => 11,
+ 'order_id' => 123,
+ 'extension_slug' => 'acme-subs',
+ 'plan_snapshot_id' => 11,
+ 'items_snapshot_id' => 22,
)
);
- $cycle->set_items_snapshot_id( 22 );
$row = $cycle->to_storage();
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
deleted file mode 100644
index c6179164aaf..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
+++ /dev/null
@@ -1,124 +0,0 @@
-<?php
-/**
- * Unit tests for the pure GatewayCapabilities declaration store.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Gateway;
-
-use InvalidArgumentException;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities
- */
-class GatewayCapabilitiesTest extends TestCase {
-
- protected function setUp(): void {
- parent::setUp();
- GatewayCapabilities::reset();
- }
-
- protected function tearDown(): void {
- GatewayCapabilities::reset();
- parent::tearDown();
- }
-
- public function test_known_capabilities_lists_every_flag(): void {
- $this->assertSame(
- array(
- 'recurring',
- 'payment_method_change',
- 'amount_changes',
- 'multiple_per_customer',
- 'gateway_scheduled_renewals',
- ),
- GatewayCapabilities::known_capabilities()
- );
- }
-
- public function test_declared_capabilities_resolve_true(): void {
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
-
- $this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_undeclared_capability_resolves_false(): void {
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
-
- $this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
- }
-
- public function test_unknown_gateway_resolves_false(): void {
- $this->assertFalse( GatewayCapabilities::is_declared( 'never-declared', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_declare_with_unknown_flag_throws(): void {
- $this->expectException( InvalidArgumentException::class );
-
- GatewayCapabilities::declare( 'dummy', array( 'totally_made_up' ) );
- }
-
- public function test_declare_rejects_the_whole_batch_when_one_flag_is_unknown(): void {
- try {
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING, 'bogus' ) );
- $this->fail( 'Expected an InvalidArgumentException for the unknown flag.' );
- } catch ( InvalidArgumentException $e ) {
- // The valid flag in the same batch must not have been stored.
- $this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- }
- }
-
- public function test_redeclaration_replaces_rather_than_merges(): void {
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING, GatewayCapabilities::AMOUNT_CHANGES ) );
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::PAYMENT_METHOD_CHANGE ) );
-
- $this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::PAYMENT_METHOD_CHANGE ) );
- $this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- $this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
- }
-
- public function test_duplicate_flags_are_deduplicated(): void {
- GatewayCapabilities::declare(
- 'dummy',
- array(
- GatewayCapabilities::RECURRING,
- GatewayCapabilities::RECURRING,
- GatewayCapabilities::AMOUNT_CHANGES,
- )
- );
-
- // Behaviourally a duplicate must not change the answer; declaring twice
- // is still a single positive declaration.
- $this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- $this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
- }
-
- public function test_empty_declaration_clears_a_gateway(): void {
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
- GatewayCapabilities::declare( 'dummy', array() );
-
- $this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_declarations_are_isolated_per_gateway(): void {
- GatewayCapabilities::declare( 'a', array( GatewayCapabilities::RECURRING ) );
- GatewayCapabilities::declare( 'b', array( GatewayCapabilities::AMOUNT_CHANGES ) );
-
- $this->assertTrue( GatewayCapabilities::is_declared( 'a', GatewayCapabilities::RECURRING ) );
- $this->assertFalse( GatewayCapabilities::is_declared( 'a', GatewayCapabilities::AMOUNT_CHANGES ) );
- $this->assertTrue( GatewayCapabilities::is_declared( 'b', GatewayCapabilities::AMOUNT_CHANGES ) );
- $this->assertFalse( GatewayCapabilities::is_declared( 'b', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_reset_clears_all_declarations(): void {
- GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
- GatewayCapabilities::reset();
-
- $this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- }
-}
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
deleted file mode 100644
index 62fc5f3e865..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
+++ /dev/null
@@ -1,172 +0,0 @@
-<?php
-/**
- * Unit tests for RenewalCalculator.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator
- */
-class RenewalCalculatorTest extends TestCase {
-
- private function policy( ?int $max_cycles, string $period = 'month', int $interval = 1 ): BillingPolicy {
- return new BillingPolicy( $period, $interval, null, $max_cycles, null );
- }
-
- /**
- * The standard next-cycle inputs, overridable per test.
- *
- * @param array<string, mixed> $overrides Values to override.
- * @return array<string, mixed>
- */
- private function values( array $overrides = array() ): array {
- return array_merge(
- array(
- 'contract_id' => 7,
- 'sequence_no' => 2,
- 'count' => 2,
- 'period_start' => '2026-02-15 00:00:00',
- 'expected_total' => '19.99',
- 'currency' => 'USD',
- 'extension_slug' => 'lite',
- 'plan_snapshot_id' => 11,
- 'items_snapshot_id' => 12,
- ),
- $overrides
- );
- }
-
- public function test_open_ended_policy_never_reaches_max_cycles(): void {
- $policy = $this->policy( null );
-
- $this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 0 ) );
- $this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 99 ) );
- }
-
- public function test_not_terminal_below_max_cycles(): void {
- $policy = $this->policy( 3 );
-
- $this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 0 ) );
- $this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 2 ) );
- }
-
- public function test_terminal_at_max_cycles(): void {
- $policy = $this->policy( 3 );
-
- $this->assertTrue( RenewalCalculator::has_reached_max_cycles( $policy, 3 ) );
- }
-
- public function test_terminal_when_over_counted(): void {
- $policy = $this->policy( 3 );
-
- $this->assertTrue( RenewalCalculator::has_reached_max_cycles( $policy, 4 ) );
- }
-
- public function test_next_bill_date_adds_one_cadence_in_utc(): void {
- $policy = $this->policy( null, 'month', 1 );
- $anchor = new DateTimeImmutable( '2026-01-15 10:00:00', new DateTimeZone( 'UTC' ) );
-
- $next = RenewalCalculator::next_bill_date( $policy, $anchor );
-
- $this->assertSame( '2026-02-15 10:00:00', $next->format( 'Y-m-d H:i:s' ) );
- $this->assertSame( 'UTC', $next->getTimezone()->getName() );
- }
-
- public function test_next_bill_date_honours_interval(): void {
- $policy = $this->policy( null, 'week', 2 );
- $anchor = new DateTimeImmutable( '2026-03-01 00:00:00', new DateTimeZone( 'UTC' ) );
-
- $next = RenewalCalculator::next_bill_date( $policy, $anchor );
-
- $this->assertSame( '2026-03-15 00:00:00', $next->format( 'Y-m-d H:i:s' ) );
- }
-
- public function test_next_bill_date_normalizes_non_utc_anchor_to_utc(): void {
- $policy = $this->policy( null, 'day', 1 );
- // 2026-01-15 23:30 in a +05:00 zone is 2026-01-15 18:30 UTC; adding a day lands on 2026-01-16 18:30 UTC.
- $anchor = new DateTimeImmutable( '2026-01-15 23:30:00', new DateTimeZone( '+05:00' ) );
-
- $next = RenewalCalculator::next_bill_date( $policy, $anchor );
-
- $this->assertSame( '2026-01-16 18:30:00', $next->format( 'Y-m-d H:i:s' ) );
- $this->assertSame( 'UTC', $next->getTimezone()->getName() );
- }
-
- /**
- * @testdox compute_next_cycle runs the period one cadence forward from the anchor.
- */
- public function test_compute_next_cycle_runs_one_cadence_forward(): void {
- $cycle = RenewalCalculator::compute_next_cycle( $this->policy( null, 'month', 1 ), $this->values() );
-
- // Monthly cadence: the period ends one month after its start - a real one-cadence
- // period, never zero-duration.
- $this->assertSame( '2026-02-15 00:00:00', $cycle->get_starts_at_gmt() );
- $this->assertSame( '2026-03-15 00:00:00', $cycle->get_ends_at_gmt() );
- $this->assertNotSame( $cycle->get_starts_at_gmt(), $cycle->get_ends_at_gmt() );
- }
-
- /**
- * @testdox compute_next_cycle honours the cadence interval and period.
- */
- public function test_compute_next_cycle_honours_cadence(): void {
- $cycle = RenewalCalculator::compute_next_cycle(
- $this->policy( null, 'week', 2 ),
- $this->values( array( 'period_start' => '2026-03-01 00:00:00' ) )
- );
-
- $this->assertSame( '2026-03-01 00:00:00', $cycle->get_starts_at_gmt() );
- $this->assertSame( '2026-03-15 00:00:00', $cycle->get_ends_at_gmt() );
- }
-
- /**
- * @testdox compute_next_cycle returns a pending cycle carrying the passed count, amount, currency, and snapshots.
- */
- public function test_compute_next_cycle_carries_passed_values(): void {
- $cycle = RenewalCalculator::compute_next_cycle(
- $this->policy( null, 'month', 1 ),
- $this->values(
- array(
- 'count' => 5,
- 'sequence_no' => 5,
- 'expected_total' => '24.50',
- 'currency' => 'EUR',
- )
- )
- );
-
- $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() );
- $this->assertEquals( 24.50, (float) $cycle->get_expected_total() );
- $this->assertSame( 'EUR', $cycle->get_currency() );
- $this->assertSame( 11, $cycle->get_plan_snapshot_id() );
- $this->assertSame( 12, $cycle->get_items_snapshot_id() );
- }
-
- /**
- * @testdox compute_next_cycle is calendar-aware for month-end roll-over.
- */
- public function test_compute_next_cycle_is_calendar_aware(): void {
- $cycle = RenewalCalculator::compute_next_cycle(
- $this->policy( null, 'month', 1 ),
- $this->values( array( 'period_start' => '2026-01-31 00:00:00' ) )
- );
-
- // One month from 31 Jan rolls to 3 Mar (Feb has no 31st), via the billing policy.
- $this->assertSame( '2026-01-31 00:00:00', $cycle->get_starts_at_gmt() );
- $this->assertSame( '2026-03-03 00:00:00', $cycle->get_ends_at_gmt() );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Dispatch/DueDispatcherWithoutActionSchedulerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Dispatch/DueDispatcherWithoutActionSchedulerTest.php
new file mode 100644
index 00000000000..4a4df16d9c0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Dispatch/DueDispatcherWithoutActionSchedulerTest.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Unit tests for the DueDispatcher schedule check without Action Scheduler.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Dispatch;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
+
+/**
+ * The unit suite loads neither WordPress nor Action Scheduler, the state a deactivation
+ * hook runs in while WooCommerce is inactive.
+ *
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher
+ */
+class DueDispatcherWithoutActionSchedulerTest extends TestCase {
+
+ /**
+ * @testdox ensure_scheduled is a no-op when Action Scheduler is not loaded.
+ */
+ public function test_ensure_scheduled_is_a_no_op_without_action_scheduler(): void {
+ $this->assertFalse( function_exists( 'as_unschedule_all_actions' ), 'Precondition: Action Scheduler is not loaded.' );
+
+ DueDispatcher::ensure_scheduled();
+
+ $this->addToAssertionCount( 1 );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
deleted file mode 100644
index fab1d541737..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
+++ /dev/null
@@ -1,103 +0,0 @@
-<?php
-/**
- * Unit tests for the WordPress-facing CapabilityRegistry.
- *
- * The unit bootstrap stubs no WordPress functions, so these tests load a
- * namespaced `apply_filters()` double (see capability-registry-filter-stub.php)
- * to exercise resolution steps 1 (Core declarations) and 3 (the override
- * filter). Step 2 - reading a live `WC_Payment_Gateway` instance's `$supports`
- * array off `WC()->payment_gateways()` - needs a booted WooCommerce and is
- * covered by integration tests, not here.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Gateway;
-
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
-
-require_once __DIR__ . '/capability-registry-filter-stub.php';
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry
- */
-class CapabilityRegistryTest extends TestCase {
-
- protected function setUp(): void {
- parent::setUp();
- GatewayCapabilities::reset();
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] = array();
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = null;
- }
-
- protected function tearDown(): void {
- GatewayCapabilities::reset();
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] = array();
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = null;
- parent::tearDown();
- }
-
- public function test_declare_compatibility_delegates_to_core(): void {
- CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
-
- $this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_supports_true_for_a_core_declaration(): void {
- CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
-
- $this->assertTrue( CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_supports_false_for_an_undeclared_capability(): void {
- CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
-
- $this->assertFalse( CapabilityRegistry::supports( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
- }
-
- public function test_supports_false_for_an_unknown_gateway(): void {
- $this->assertFalse( CapabilityRegistry::supports( 'never-declared', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_supports_passes_the_resolution_chain_through_the_filter(): void {
- CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
-
- CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING );
-
- $calls = $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'];
- $this->assertIsArray( $calls );
- $this->assertCount( 1, $calls );
- $this->assertIsArray( $calls[0] );
- $this->assertSame( CapabilityRegistry::CAPABILITY_CHECK_FILTER, $calls[0]['hook'] );
- // The pre-filter value is the steps-1-2 result: true for a declared cap.
- $this->assertTrue( $calls[0]['value'] );
- // Filter args are gateway id, capability, then the order context (null).
- $this->assertSame( array( 'dummy', GatewayCapabilities::RECURRING, null ), $calls[0]['args'] );
- }
-
- public function test_filter_can_override_a_true_to_false(): void {
- CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = false;
-
- $this->assertFalse( CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING ) );
- }
-
- public function test_filter_can_override_a_false_to_true(): void {
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = true;
-
- $this->assertTrue( CapabilityRegistry::supports( 'undeclared', GatewayCapabilities::AMOUNT_CHANGES ) );
- }
-
- public function test_supports_casts_a_non_bool_filter_result(): void {
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = '1';
-
- $result = CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING );
-
- $this->assertIsBool( $result );
- $this->assertTrue( $result );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
deleted file mode 100644
index 64fac28b578..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
+++ /dev/null
@@ -1,42 +0,0 @@
-<?php
-/**
- * Namespaced `apply_filters()` stub for CapabilityRegistry unit tests.
- *
- * Defined in the same namespace as CapabilityRegistry so its unqualified
- * `apply_filters()` call resolves here instead of WordPress core, which the
- * unit bootstrap does not load. The stub records each call and returns either a
- * configured override or the passed-through value (the WordPress default when
- * no callback is attached).
- *
- * Kept in its own file (not the *Test.php class file) so the PSR-12 "one
- * namespace per file" / "functions separate from OO" sniffs stay satisfied.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway;
-
-if ( ! function_exists( __NAMESPACE__ . '\\apply_filters' ) ) {
- /**
- * Namespaced test double for WordPress `apply_filters()`.
- *
- * @param string $hook Filter hook name.
- * @param mixed $value Value being filtered.
- * @param mixed ...$args Additional filter arguments.
- * @return mixed Configured override, or `$value` unchanged.
- */
- function apply_filters( string $hook, $value, ...$args ) {
- if ( ! isset( $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] ) || ! is_array( $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] ) ) {
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] = array();
- }
- $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'][] = array(
- 'hook' => $hook,
- 'value' => $value,
- 'args' => $args,
- );
-
- return $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] ?? $value;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php
deleted file mode 100644
index ad8cd4d2b9e..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php
+++ /dev/null
@@ -1,99 +0,0 @@
-<?php
-/**
- * Unit tests for the ConsumerRegistry (the set of engine-consuming extensions).
- *
- * The registry is WordPress-free (only the file-access guard), so it loads and
- * runs in the autoloader-only unit bootstrap.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Ownership;
-
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry
- */
-class ConsumerRegistryTest extends TestCase {
-
- protected function setUp(): void {
- parent::setUp();
- ConsumerRegistry::reset();
- }
-
- protected function tearDown(): void {
- ConsumerRegistry::reset();
- parent::tearDown();
- }
-
- public function test_is_empty_when_nothing_is_registered(): void {
- $this->assertTrue( ConsumerRegistry::is_empty() );
- $this->assertSame( array(), ConsumerRegistry::all() );
- }
-
- public function test_register_records_a_consumer(): void {
- ConsumerRegistry::register( 'lite' );
-
- $this->assertFalse( ConsumerRegistry::is_empty() );
- $this->assertSame( array( 'lite' ), ConsumerRegistry::all() );
- }
-
- public function test_register_is_idempotent_for_the_same_slug(): void {
- ConsumerRegistry::register( 'lite' );
- ConsumerRegistry::register( 'lite' );
-
- $this->assertSame( array( 'lite' ), ConsumerRegistry::all() );
- }
-
- public function test_register_records_multiple_consumers(): void {
- ConsumerRegistry::register( 'lite' );
- ConsumerRegistry::register( 'premium' );
-
- $this->assertFalse( ConsumerRegistry::is_empty() );
-
- $all = ConsumerRegistry::all();
- sort( $all );
- $this->assertSame( array( 'lite', 'premium' ), $all );
- }
-
- public function test_register_ignores_an_empty_slug(): void {
- ConsumerRegistry::register( '' );
-
- $this->assertTrue( ConsumerRegistry::is_empty(), 'A blank slug must not flip the gate open.' );
- $this->assertSame( array(), ConsumerRegistry::all() );
- }
-
- public function test_register_ignores_a_whitespace_only_slug_and_trims(): void {
- ConsumerRegistry::register( " \t\n" );
- $this->assertTrue( ConsumerRegistry::is_empty(), 'A whitespace-only slug must not open the gate.' );
-
- ConsumerRegistry::register( ' woocommerce-subscriptions-lite ' );
- $this->assertSame( array( 'woocommerce-subscriptions-lite' ), ConsumerRegistry::all(), 'The slug is stored trimmed.' );
- }
-
- public function test_unregister_removes_only_the_named_consumer(): void {
- ConsumerRegistry::register( 'lite' );
- ConsumerRegistry::register( 'premium' );
-
- ConsumerRegistry::unregister( 'lite' );
- $this->assertSame( array( 'premium' ), ConsumerRegistry::all(), 'Only the named consumer is removed.' );
-
- ConsumerRegistry::unregister( ' premium ' );
- $this->assertTrue( ConsumerRegistry::is_empty(), 'The slug is trimmed, matching register().' );
-
- ConsumerRegistry::unregister( 'unknown' );
- $this->assertTrue( ConsumerRegistry::is_empty(), 'An unknown slug is a no-op.' );
- }
-
- public function test_reset_clears_every_registration(): void {
- ConsumerRegistry::register( 'lite' );
- ConsumerRegistry::reset();
-
- $this->assertTrue( ConsumerRegistry::is_empty() );
- $this->assertSame( array(), ConsumerRegistry::all() );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php
deleted file mode 100644
index f770783e326..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php
+++ /dev/null
@@ -1,117 +0,0 @@
-<?php
-/**
- * Unit tests for the read-only renewal cycle selector.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalSelector;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalSelector
- */
-class RenewalSelectorTest extends TestCase {
-
- /**
- * The scan moment the selector decides against.
- *
- * @var DateTimeImmutable
- */
- private $now;
-
- /**
- * The selector under test.
- *
- * @var RenewalSelector
- */
- private $selector;
-
- protected function setUp(): void {
- parent::setUp();
- $this->now = new DateTimeImmutable( '2026-03-01 00:00:00', new DateTimeZone( 'UTC' ) );
- $this->selector = new RenewalSelector();
- }
-
- /**
- * @param int $count Head chargeable count.
- * @param string $status Head status.
- * @param string $ends_at Head period end (GMT string).
- */
- private function candidate( int $count, string $status, string $ends_at ): RenewalCandidate {
- return new RenewalCandidate( 42, $count, $status, $ends_at );
- }
-
- public function test_advances_to_the_next_cycle_when_a_billed_head_period_has_ended(): void {
- $this->assertSame( 2, $this->selector->select_scheduled_cycle( $this->candidate( 1, CycleStatus::BILLED, '2026-02-01 00:00:00' ), $this->now ) );
- }
-
- public function test_does_not_advance_when_the_billed_head_period_has_not_ended(): void {
- // The charge-ahead guard: a just-billed head whose period runs into the future is not
- // yet due for its successor.
- $this->assertNull( $this->selector->select_scheduled_cycle( $this->candidate( 1, CycleStatus::BILLED, '2026-04-01 00:00:00' ), $this->now ) );
- }
-
- public function test_advances_on_the_exact_period_boundary(): void {
- // ends_at == now: the period has ended, so the successor is due.
- $this->assertSame( 4, $this->selector->select_scheduled_cycle( $this->candidate( 3, CycleStatus::BILLED, '2026-03-01 00:00:00' ), $this->now ) );
- }
-
- public function test_advances_past_a_cancelled_head_that_has_ended(): void {
- $this->assertSame( 6, $this->selector->select_scheduled_cycle( $this->candidate( 5, CycleStatus::CANCELLED, '2026-02-01 00:00:00' ), $this->now ) );
- }
-
- public function test_retries_the_same_cycle_when_the_head_is_still_pending(): void {
- // A pending head only reaches the selector via the scan once its lease has expired; the
- // money-path reclaims it. Selection targets the same count, not the next.
- $this->assertSame( 7, $this->selector->select_scheduled_cycle( $this->candidate( 7, CycleStatus::PENDING, '2026-02-01 00:00:00' ), $this->now ) );
- }
-
- public function test_skips_a_failed_head(): void {
- $this->assertNull( $this->selector->select_scheduled_cycle( $this->candidate( 2, CycleStatus::FAILED, '2026-02-01 00:00:00' ), $this->now ) );
- }
-
- public function test_skips_a_processing_head(): void {
- $this->assertNull( $this->selector->select_scheduled_cycle( $this->candidate( 2, CycleStatus::PROCESSING, '2026-02-01 00:00:00' ), $this->now ) );
- }
-
- public function test_skips_a_countless_head(): void {
- $this->assertNull( $this->selector->select_scheduled_cycle( new RenewalCandidate( 42, null, CycleStatus::BILLED, '2026-02-01 00:00:00' ), $this->now ) );
- }
-
- public function test_manual_forces_the_next_cycle_regardless_of_the_due_date(): void {
- // A billed head whose period runs into the future would be skipped by the scheduled path,
- // but the admin path forces the next cycle anyway (no due-guard).
- $this->assertSame( 2, $this->selector->select_manual_cycle( $this->candidate( 1, CycleStatus::BILLED, '2026-04-01 00:00:00' ) ) );
- }
-
- public function test_manual_forces_the_next_cycle_past_a_cancelled_head(): void {
- $this->assertSame( 6, $this->selector->select_manual_cycle( $this->candidate( 5, CycleStatus::CANCELLED, '2026-04-01 00:00:00' ) ) );
- }
-
- public function test_manual_retries_a_failed_head(): void {
- // The scheduled path skips a failed head (awaits dunning); the admin path retries it.
- $this->assertSame( 2, $this->selector->select_manual_cycle( $this->candidate( 2, CycleStatus::FAILED, '2026-02-01 00:00:00' ) ) );
- }
-
- public function test_manual_retries_a_pending_head(): void {
- $this->assertSame( 7, $this->selector->select_manual_cycle( $this->candidate( 7, CycleStatus::PENDING, '2026-02-01 00:00:00' ) ) );
- }
-
- public function test_manual_skips_a_processing_head(): void {
- // A manual trigger cannot preempt an in-flight async charge.
- $this->assertNull( $this->selector->select_manual_cycle( $this->candidate( 2, CycleStatus::PROCESSING, '2026-02-01 00:00:00' ) ) );
- }
-
- public function test_manual_skips_a_countless_head(): void {
- $this->assertNull( $this->selector->select_manual_cycle( new RenewalCandidate( 42, null, CycleStatus::BILLED, '2026-02-01 00:00:00' ) ) );
- }
-}