Commit 35bd288b3b9 for woocommerce
commit 35bd288b3b99baac6adce4aa3eed3f8813428b46
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Fri Jul 3 13:03:13 2026 +0200
Add batch renewal dispatching to the subscriptions engine (#66007)
- Drive renewals from one recurring Action Scheduler job over a cycle-aware due-index, gated on a consumer registry - the engine charges nothing and holds no scan action unless a consumer extension registers
- Claim the target cycle before any order work (create-as-claim UNIQUE plus a crash-recovery lease arbitrated on the database clock), link renewal orders to cycles draft-first, and settle via atomic status compare-and-set
- Complete renewals from the order's paid state, so synchronous, asynchronous, and manually settled charges share one idempotent path
- Add admin-triggered renewals: renew_now() forces the next cycle for a settled head or retries a failed one in place, without the scheduled due-guard
- Pre-flight the gateway's recurring capability (parking unchargeable contracts), log real database failures distinctly from benign claim races, and remove the superseded per-contract scheduler
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-dispatch b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-dispatch
new file mode 100644
index 00000000000..754a7e1801d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-dispatch
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add subscriptions engine batch renewal dispatcher with cycle-aware selection, order-driven completion, admin-triggered renew and retry, and the processing gate.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index 1775d2b05c0..5992360a64f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -89,12 +89,15 @@ final class Subscriptions {
}
/**
- * Run the contract's renewal now (advance the billing chain a cycle).
+ * 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() )->process_due( $contract_id );
+ return ( new RenewalEngine() )->renew_now( $contract_id );
}
}
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 be41fc42d19..6abe8d370b9 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -141,6 +141,23 @@ 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.
+ *
+ * @var string|null
+ */
+ private $claimed_until_gmt;
+
+ /**
+ * Reserved retry moment (GMT string), or null. Additive column for a later
+ * retry/dunning pass; not wired by the dispatcher yet.
+ *
+ * @var string|null
+ */
+ private $retry_at_gmt;
+
/**
* Typed plan snapshot held in memory, or null.
*
@@ -177,6 +194,8 @@ final class Cycle {
$this->items_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
$this->order_id = ScalarCoercion::coerce_nullable_int( $data['order_id'] ?? null );
$this->extension_slug = ScalarCoercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+ $this->claimed_until_gmt = ScalarCoercion::coerce_nullable_string( $data['claimed_until'] ?? null );
+ $this->retry_at_gmt = ScalarCoercion::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;
}
@@ -427,6 +446,39 @@ final class Cycle {
return $this->extension_slug;
}
+ /**
+ * Crash-recovery lease 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.
+ */
+ public function get_retry_at_gmt(): ?string {
+ 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.
*/
@@ -491,6 +543,8 @@ final class Cycle {
'items_snapshot_id' => $this->items_snapshot_id,
'order_id' => $this->order_id,
'extension_slug' => $this->extension_slug,
+ 'claimed_until' => $this->claimed_until_gmt,
+ 'retry_at' => $this->retry_at_gmt,
);
}
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 59f081d03db..7a29582015f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -4,10 +4,14 @@
* Owns the valid statuses and allowed transitions so an invalid state cannot be
* represented. Mirrors {@see ContractStatus}.
*
- * Lifecycle: a cycle is born `pending`, settles to `billed` (terminal) or `failed`,
- * and any non-settled cycle can be `cancelled` (terminal). `failed` is deliberately
- * non-terminal so a later change can add a retry edge. Instance methods serve the
- * entity; the static string helpers operate on raw strings at the storage boundary.
+ * Lifecycle: a cycle is born `pending`; a charge submitted to a gateway that has not
+ * yet returned a terminal outcome (an async method awaiting confirmation) is
+ * `processing`; it settles to `billed` (terminal) or `failed`. A `failed` cycle can be
+ * retried back to `pending` (an admin-triggered re-attempt), and any non-settled cycle
+ * can be `cancelled` (terminal). The state is shared with the shipping chain, so
+ * `processing` names "submitted, awaiting a terminal outcome" without payment-specific wording.
+ * Instance methods serve the entity; the static string helpers operate on raw strings at
+ * the storage boundary.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
*/
@@ -28,10 +32,11 @@ defined( 'ABSPATH' ) || exit;
*/
final class CycleStatus {
- public const PENDING = 'pending';
- public const BILLED = 'billed';
- public const FAILED = 'failed';
- public const CANCELLED = 'cancelled';
+ public const PENDING = 'pending';
+ public const PROCESSING = 'processing';
+ public const BILLED = 'billed';
+ public const FAILED = 'failed';
+ public const CANCELLED = 'cancelled';
/**
* The status string this value wraps.
@@ -72,6 +77,13 @@ final class CycleStatus {
return new self( self::PENDING );
}
+ /**
+ * The `processing` status (charge submitted, awaiting a terminal outcome; non-terminal).
+ */
+ public static function processing(): self {
+ return new self( self::PROCESSING );
+ }
+
/**
* The `billed` status (settled after a successful charge; terminal).
*/
@@ -138,6 +150,7 @@ final class CycleStatus {
public static function all(): array {
return array(
self::PENDING,
+ self::PROCESSING,
self::BILLED,
self::FAILED,
self::CANCELLED,
@@ -213,10 +226,11 @@ final class CycleStatus {
*/
private static function transitions(): array {
return array(
- self::PENDING => array( self::BILLED, self::FAILED, self::CANCELLED ),
- self::BILLED => array(),
- self::FAILED => array( self::CANCELLED ),
- self::CANCELLED => array(),
+ self::PENDING => array( self::PROCESSING, self::BILLED, self::FAILED, self::CANCELLED ),
+ self::PROCESSING => array( self::BILLED, self::FAILED, self::CANCELLED ),
+ self::BILLED => array(),
+ self::FAILED => array( self::PENDING, self::CANCELLED ),
+ self::CANCELLED => array(),
);
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 68a64613c91..ee13600bbb7 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -15,6 +15,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+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;
@@ -46,23 +47,28 @@ final class Bootstrap {
CapabilityRegistry::init();
- // Register the Action Scheduler callback that dispatches a due renewal
- // back into the engine. Must run on every boot (not just activation) so
- // AS can fire scheduled renewals.
- RenewalEngine::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();
+ ( new RenewalDispatcher() )->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.
if ( did_action( 'init' ) ) {
- self::maybe_install_schema();
+ SchemaInstaller::maybe_install();
} else {
- add_action( 'init', array( __CLASS__, 'maybe_install_schema' ) );
+ add_action( 'init', array( SchemaInstaller::class, 'maybe_install' ) );
}
- }
- /**
- * Install or upgrade the engine schema when it is missing or behind.
- */
- public static function maybe_install_schema(): void {
- SchemaInstaller::maybe_install();
+ if ( did_action( 'action_scheduler_init' ) ) {
+ RenewalDispatcher::ensure_scheduled();
+ } else {
+ add_action( 'action_scheduler_init', array( RenewalDispatcher::class, 'ensure_scheduled' ) );
+ }
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
index 18fc655947e..f70b6c180ce 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -1,11 +1,12 @@
<?php
/**
* Builds and persists a {@see Contract} (plus its origin {@see Cycle}) from a paid
- * checkout order, and links order <-> contract in both directions. Does not schedule
- * the first renewal - the caller arms that separately via {@see RenewalEngine::schedule()}.
+ * checkout order, and links order <-> contract in both directions. Renewals need no
+ * arming beyond this: the contract's `next_payment_gmt` places it on the due index the
+ * batch dispatcher scans.
*
- * Integration zone: WordPress-native. Reads a live `WC_Order`; the order never
- * crosses into Core - only the snapshot values pulled off it do.
+ * Reads a live `WC_Order`; the order never crosses into Core - only the snapshot
+ * values pulled off it do.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
index dd47640f9b2..9fa91cee1cc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -5,8 +5,8 @@
* `origin_order_id`). The engine owns these keys; consumers read them through
* this class rather than hard-coding the strings.
*
- * Integration zone: WordPress-native. Written to WooCommerce order meta, which
- * works under both HPOS and the legacy CPT order store.
+ * Written to WooCommerce order meta, which works under both HPOS and the legacy
+ * CPT order store.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
index c484fd103ba..a9b57d8608f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -18,7 +18,6 @@ use RuntimeException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
defined( 'ABSPATH' ) || exit;
@@ -50,13 +49,14 @@ final class Cancellation {
}
/**
- * Cancel `$contract`: transition to cancelled, close any mid-charge cycle, and clear its
- * pending renewal.
+ * Cancel `$contract`: transition to cancelled and close any mid-charge cycle.
*
* Status moves through the Core state machine ({@see Contract::set_status()}), which raises
* a `DomainException` on an illegal transition. When the chain's most-recent cycle is still
* `pending` (a charge caught mid-flight) it is transitioned `cancelled` so a stale claim is
- * not left open; a settled cycle is untouched.
+ * not left open; a settled cycle is untouched. The due scan only selects active contracts,
+ * so a cancelled contract simply stops being picked up - there is no per-contract schedule
+ * to clear.
*
* @param Contract $contract Contract to cancel. Must have an id.
* @return bool True when the contract was cancelled and persisted.
@@ -73,16 +73,14 @@ final class Cancellation {
// Close a charge caught mid-flight: a still-pending head cycle is cancelled so no stale
// claim is left open. A settled (billed/failed/cancelled) cycle is left as is.
- $current = $this->contracts->find_current_cycle( $id );
+ $current = $this->contracts->find_chain_head( $id );
if ( null !== $current && $current->get_status()->equals( CycleStatus::pending() ) ) {
$current->set_status( CycleStatus::cancelled() );
$this->contracts->update_cycle( $current );
}
- RenewalScheduler::unschedule( $id );
-
/**
- * Fires after a contract is cancelled and its pending renewal cleared.
+ * Fires after a contract is cancelled.
*
* @param Contract $contract The cancelled contract.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
index 975b974b07c..40854371bef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
@@ -11,8 +11,6 @@
* and adds the live-gateway lookup, the override filter, and the ready-hook
* wiring on top.
*
- * Integration zone: WordPress-native. May use WooCommerce.
- *
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
new file mode 100644
index 00000000000..85b52844e84
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * 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.
+ *
+ * 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}.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registry of the extensions consuming engine functionality.
+ */
+final class ConsumerRegistry {
+
+ /**
+ * Registered consumer slugs, de-duplicated (a set keyed by slug).
+ *
+ * @var array<string, true>
+ */
+ private static $slugs = array();
+
+ /**
+ * Register a consumer extension by its slug.
+ *
+ * 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.
+ *
+ * @param string $slug The consumer extension's registered slug.
+ */
+ public static function register( string $slug ): void {
+ $slug = trim( $slug );
+ if ( '' === $slug ) {
+ return;
+ }
+
+ self::$slugs[ $slug ] = true;
+ }
+
+ /**
+ * 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.
+ *
+ * @param string $slug The consumer extension's registered slug.
+ */
+ public static function unregister( string $slug ): void {
+ unset( self::$slugs[ trim( $slug ) ] );
+ }
+
+ /**
+ * Whether no consumer is registered. The dispatcher gate: true means charge
+ * nothing this run.
+ */
+ public static function is_empty(): bool {
+ return array() === self::$slugs;
+ }
+
+ /**
+ * The registered consumer slugs (order not significant).
+ *
+ * @return array<int, string>
+ */
+ public static function all(): array {
+ return array_keys( self::$slugs );
+ }
+
+ /**
+ * Clear every registration.
+ *
+ * @internal Public only so test setUp can isolate per-test state. Not part
+ * of the consumer API.
+ */
+ public static function reset(): void {
+ self::$slugs = array();
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
new file mode 100644
index 00000000000..3be141144f3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalDispatcher.php
@@ -0,0 +1,287 @@
+<?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. 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). Gated on the consumer registry: a store with no consumer extension runs no
+ * renewals, so it carries no recurring scan action either - one already scheduled is
+ * removed on the first gated boot after the last consumer deactivates. To avoid an Action Scheduler
+ * 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: gate, then drive every due renewal.
+ *
+ * The processing gate comes first - with no registered consumer the engine charges
+ * nothing and the run returns immediately. Otherwise the cycle-aware scan returns the
+ * actionable contracts due at `$now`; each is run through read-only selection and, when a
+ * cycle is due, billed via {@see RenewalEngine::process()}. A pre-flight impossibility
+ * ({@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;
+ }
+
+ if ( ConsumerRegistry::is_empty() ) {
+ 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 );
+
+ $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
index bba17af32b2..0f44d08e888 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -1,17 +1,26 @@
<?php
/**
- * The seam between consumers and the renewal money-path: `schedule()` enqueues a
- * contract's next renewal, `process_due()` runs it when fired (by Action Scheduler
- * or driven directly). Wraps Action Scheduler (whose hook names and dedup behaviour
- * stay private) and adds the contract-aware semantics: capability gating, the renewal
- * order, the charge. One AS job per contract; the AS coupling lives in {@see RenewalScheduler}.
+ * The renewal money-path, in two separated concerns joined by a {@see RenewalIntent}:
*
- * `process_due()` advances the billing chain at fire time - it claims the next cycle
- * `pending` (create-as-claim), charges its `expected_total`, then settles `billed` or
- * `failed` and advances the contract schedule on success. It stays a single synchronous
- * entry; re-arming the next due moment via a recurring scan is a later slice.
+ * - 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.
*
- * Integration zone: WordPress-native. Action Scheduler, WC orders, gateways.
+ * 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
*/
@@ -25,6 +34,7 @@ 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;
@@ -32,26 +42,23 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
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 - schedule, advance, charge, cancel.
+ * Renewal engine - select, process, complete.
*/
final class RenewalEngine {
- /**
- * Action fired after a contract is scheduled, with `( $contract, $when )`.
- * Listeners observe a scheduled state, not an in-flight one.
- */
- public const RENEWAL_SCHEDULED_ACTION = 'woocommerce_subscriptions_engine_renewal_scheduled';
-
/**
* Action fired after a renewal order is created, with `( $renewal_order, $contract )`.
*/
@@ -68,6 +75,16 @@ final class RenewalEngine {
*/
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.
*
@@ -83,124 +100,139 @@ final class RenewalEngine {
private $plans;
/**
- * Build a renewal engine over the given repositories.
+ * 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 ) {
+ 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 Action Scheduler callback. Must run on every page load (not just
- * activation) so AS can dispatch a due action back into {@see self::handle_due_action()}.
+ * 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 static function register_hooks(): void {
- add_action( RenewalScheduler::HOOK, array( __CLASS__, 'handle_due_action' ), 10, 1 );
+ 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 );
+ }
}
/**
- * Action Scheduler dispatch entry point - fires when a renewal is due.
- *
- * Static so it can be registered as a plain callback; routes through the instance
- * `process_due()` so dispatch and any synchronous test driver share one code path.
+ * 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 $contract_id Contract whose renewal is firing.
+ * @param int $order_id The order whose state changed.
*/
- public static function handle_due_action( int $contract_id ): void {
- ( new self() )->process_due( $contract_id );
+ 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 );
+ }
}
/**
- * Schedule (or re-schedule) the next renewal for `$contract` at its `next_payment_gmt`.
- *
- * Clear-then-enqueue keeps at most one pending AS row per contract, so callers
- * moving the date forward just call `schedule()` again. Skips (and clears any stale
- * row) when the contract is gateway-scheduled (the gateway runs its own schedule) or
- * has no `next_payment_gmt`. Capability gate: a primitive-scheduled contract is only
- * enqueued when its gateway declares the `recurring` capability via
- * {@see CapabilityRegistry::supports()}, so renewals nothing can charge are refused
- * at the boundary rather than failing later on a customer-facing order.
- *
- * @param Contract $contract Contract to schedule. Must have an id.
- * @return bool True when an AS row was enqueued; false when scheduling was
- * skipped (gateway-scheduled, incapable gateway, no date, no id).
+ * 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 schedule( Contract $contract ): bool {
- $id = $contract->get_id();
- if ( null === $id ) {
- return false;
- }
+ public function renew_now( int $contract_id, ?DateTimeImmutable $now = null ): ?WC_Order {
+ $now = $now ?? new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
- // Gateway-scheduled: the gateway owns the schedule. Clear any stale row and bail.
- if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
- RenewalScheduler::unschedule( $id );
- return false;
+ $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;
}
- $next_payment_gmt = $contract->get_next_payment_gmt();
- if ( null === $next_payment_gmt ) {
- RenewalScheduler::unschedule( $id );
- return false;
+ $cycle_count = $this->selector->select_manual_cycle( RenewalCandidate::from_cycle( $head ) );
+ if ( null === $cycle_count ) {
+ return null;
}
- // Schedule-time capability gate.
- $gateway_id = $contract->get_payment_instrument()->get_gateway();
- if ( null === $gateway_id || '' === $gateway_id || ! CapabilityRegistry::supports( (string) $gateway_id, GatewayCapabilities::RECURRING ) ) {
- RenewalScheduler::unschedule( $id );
+ try {
+ return $this->process( new RenewalIntent( $contract_id, $cycle_count ), $now );
+ } catch ( RenewalNotProcessable $e ) {
wc_get_logger()->warning(
- sprintf(
- 'RenewalEngine::schedule(): not scheduling contract %d - gateway "%s" does not declare the "recurring" capability. Declare it via CapabilityRegistry, or set the contract to gateway-scheduled if the gateway runs its own renewals.',
- $id,
- (string) $gateway_id
- ),
+ sprintf( 'RenewalEngine::renew_now(): cannot renew contract %d. %s', $contract_id, $e->getMessage() ),
array(
'source' => self::LOG_SOURCE,
- 'contract_id' => $id,
- 'gateway_id' => (string) $gateway_id,
+ 'contract_id' => $contract_id,
)
);
- return false;
+ return null;
}
-
- $when = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );
-
- // Clear-then-enqueue: AS does not dedup on hook+args, so without the clear a
- // re-schedule would leave two rows and fire twice.
- RenewalScheduler::unschedule( $id );
- RenewalScheduler::schedule( $id, $when );
-
- do_action( self::RENEWAL_SCHEDULED_ACTION, $contract, $when );
-
- return true;
}
/**
- * Run the renewal due for `$contract_id`. Fired by the AS hook.
- *
- * Loads the contract and skips (logging only, never throwing - AS would retry a
- * permanent failure forever) when it is gone, gateway-scheduled, or not active.
- * Then advances the billing chain: it creates the next cycle `pending` as the
- * create-as-claim (the `UNIQUE(contract_id, kind, count)` index makes a concurrent
- * or retried fire a no-op), builds and charges the renewal order at that cycle's
- * `expected_total`, and resolves the outcome - on a paid order the cycle settles
- * `billed`, the order is linked, and the contract schedule advances one cadence; on
- * an unpaid order the cycle settles `failed` and the schedule is left untouched.
- *
- * Writes are ordered durable-intent-first (cycle create -> charge -> cycle resolve ->
- * contract advance) with no surrounding transaction. The single synchronous entry a
- * later batch dispatcher calls per-claimed-contract.
- *
- * @param int $contract_id Contract whose renewal cycle is firing.
- * @return WC_Order|null The created renewal order, or null when skipped/idempotent.
+ * 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 inactive, and refuses a cycle
+ * that is neither the head nor its immediate successor (no billing a gap). 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 chain, an
+ * unresolvable plan, a non-adjacent count, a gateway that cannot charge renewals) so the
+ * caller can park; returns null for an idempotent no-op (a live claim, an already-settled
+ * cycle, an unbuildable order).
+ *
+ * @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_due( int $contract_id ): ?WC_Order {
+ 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_due(): unknown contract %d - skipping (stale scheduled action).', $contract_id ),
+ sprintf( 'RenewalEngine::process(): unknown contract %d - skipping (stale scheduled action).', $contract_id ),
array(
'source' => self::LOG_SOURCE,
'contract_id' => $contract_id,
@@ -211,7 +243,7 @@ final class RenewalEngine {
if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
wc_get_logger()->warning(
- sprintf( 'RenewalEngine::process_due(): contract %d is gateway-scheduled - skipping. The gateway owns the renewal; this primitive row should not have fired.', $contract_id ),
+ 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,
@@ -222,7 +254,7 @@ final class RenewalEngine {
if ( ContractStatus::ACTIVE !== $contract->get_status() ) {
wc_get_logger()->info(
- sprintf( 'RenewalEngine::process_due(): contract %d is %s, not active - skipping renewal. No order created.', $contract_id, $contract->get_status() ),
+ 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,
@@ -232,123 +264,104 @@ final class RenewalEngine {
return null;
}
- $previous = $this->contracts->find_current_cycle( $contract_id );
- if ( null === $previous ) {
- // No billing chain to advance: checkout always creates cycle 1, so a chainless
- // contract is a manual/corrupt case the engine does not renew. Skip without throwing
- // (never silently bill it as cycle 1) so a scheduled action does not retry forever.
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::process_due(): contract %d has no billing chain to advance - skipping.', $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
+ // 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 ) )
);
- return null;
}
- $next_count = $this->target_count( $previous );
+ $head = $this->contracts->find_chain_head( $contract_id );
+ if ( null === $head ) {
+ throw new RenewalNotProcessable( 'no billing chain to advance' );
+ }
- // Idempotency pre-check: a renewal order already tagged for this number means the
- // action already ran (AS retry, double-fire). Bail before claiming a new cycle.
- if ( $this->renewal_exists_for_cycle( $contract_id, $next_count ) ) {
- wc_get_logger()->info(
- sprintf( 'RenewalEngine::process_due(): renewal for contract %d cycle %d already exists - skipping (idempotent retry).', $contract_id, $next_count ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- return null;
+ $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() ) ) );
}
- // Resolve the billing cadence from the contract's plan snapshot. A deleted/unresolvable
- // plan is a recoverable data condition, not a fatal: skip (logging only) like the guards
- // above so a scheduled action does not retry a permanent failure forever.
- $policy = $this->resolve_billing_policy( $contract );
- if ( null === $policy ) {
- wc_get_logger()->warning(
- sprintf( 'RenewalEngine::process_due(): cannot resolve the billing plan for contract %d - skipping. The selling plan may have been deleted.', $contract_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_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 ) )
);
- return null;
}
- // Build the next cycle from the contract's live values (amount, currency, snapshots),
- // one cadence forward from the anchor.
- $new_cycle = RenewalCalculator::compute_next_cycle(
- $policy,
- array(
- 'contract_id' => $contract_id,
- 'sequence_no' => $previous->get_sequence_no() + 1,
- 'count' => $next_count,
- 'period_start' => $previous->get_ends_at_gmt(),
- 'expected_total' => $contract->get_billing_total(),
- 'currency' => $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(),
- )
- );
-
- // Create-as-claim: the cycle is inserted `pending` before any charge. A concurrent or
- // duplicate fire loses the UNIQUE(contract_id, kind, count) race and is an idempotent no-op.
- if ( ! $this->claim_cycle( $new_cycle, $previous ) ) {
+ if ( null === $cycle ) {
return null;
}
- $renewal_order = $this->build_renewal_order( $contract, $next_count, $new_cycle->get_expected_total() );
+ // 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 ) {
- // build_renewal_order logged the reason. The claimed cycle stays pending for
- // a later run/dunning to resolve; no schedule change is made here.
- return null;
+ $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 );
}
- do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
-
- $this->attempt_charge( $renewal_order, $contract );
-
- $this->resolve_outcome( $contract, $new_cycle, $renewal_order );
-
- return $renewal_order;
- }
+ // 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();
+ }
- /**
- * The chargeable number this renewal targets - the idempotency anchor.
- *
- * One past the head cycle's count once it has settled forward (`billed`/`cancelled`): the
- * chain advances. While the head is still unsettled (`pending`/`failed`) the same count is
- * targeted again, so a retry resolves the in-flight cycle rather than skipping a number - and
- * the order-meta pre-check / the create-as-claim UNIQUE then make the retry a no-op.
- *
- * Only called once a billing chain exists ({@see self::process_due()} skips a chainless
- * contract), so the head must carry a count; a countless head is a corrupt chain to refuse.
- *
- * @param Cycle $previous The chain's most-recent cycle.
- * @return int The chargeable number to target.
- * @throws \RuntimeException If the head cycle has no count to advance from.
- */
- private function target_count( Cycle $previous ): int {
- $count = $previous->get_count();
- if ( null === $count ) {
- // A counting renewal advances off the head cycle's count; a head with no count is a
- // corrupt chain we refuse to bill against rather than guess a number.
- throw new \RuntimeException(
- sprintf(
- 'RenewalEngine::target_count(): contract %d head cycle %d has no count to advance from.',
- (int) $previous->get_contract_id(),
- (int) $previous->get_id()
- )
- );
+ // 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 ) {
+ do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
+ }
+ $this->attempt_charge( $renewal_order, $contract );
}
- $status = $previous->get_status()->get_value();
- $settled_forward = CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status;
+ // 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 $settled_forward ? (int) $count + 1 : (int) $count;
+ return $renewal_order;
}
/**
@@ -387,108 +400,391 @@ final class RenewalEngine {
}
/**
- * Claim the freshly-computed `pending` cycle as the create-as-claim. Returns false when
- * the insert loses the `UNIQUE(contract_id, kind, count)` race (a concurrent/duplicate
- * fire) - treated as an idempotent no-op.
+ * 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 Cycle $cycle The pending cycle to claim.
- * @param Cycle|null $previous The chain's previous cycle (for snapshot copy-forward), or null.
- * @return bool True when the cycle was claimed; false when the claim was lost.
+ * @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_cycle( Cycle $cycle, ?Cycle $previous ): bool {
+ 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' => $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( $cycle, $previous );
+ $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 duplicate (contract_id, kind, count) is rejected by the UNIQUE index: the cycle
- // was already claimed by a concurrent/earlier fire. Idempotent no-op.
+ // 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( 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_due(): could not claim cycle %d for contract %d (already claimed) - skipping. %s', (int) $cycle->get_count(), $cycle->get_contract_id(), $e->getMessage() ),
+ sprintf( 'RenewalEngine::process(): cycle %d for contract %d was reclaimed by another worker - skipping.', $count, $contract_id ),
array(
'source' => self::LOG_SOURCE,
- 'contract_id' => $cycle->get_contract_id(),
+ '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( 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;
}
- return true;
+ $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();
}
/**
- * Resolve the renewal outcome from the order's paid state.
+ * 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.
*
- * Paid -> CAS the cycle `pending -> billed`, link the order, advance the contract's
- * `next_payment_gmt` (the cycle's own period end) and `last_payment_gmt`, persist. Not
- * paid -> CAS the cycle `pending -> failed` (recording a reason) and leave the contract
- * schedule unchanged for a later dunning pass.
+ * 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 Contract $contract The contract being renewed.
- * @param Cycle $cycle The claimed pending cycle to settle.
- * @param WC_Order $renewal_order The charged renewal order.
+ * @param WC_Order $order The order whose state may settle a cycle.
*/
- private function resolve_outcome( Contract $contract, Cycle $cycle, WC_Order $renewal_order ): void {
+ public function complete_from_order( WC_Order $order ): void {
+ if ( OrderLinkage::RELATION_RENEWAL !== $order->get_meta( OrderLinkage::META_RELATION_TYPE ) ) {
+ return;
+ }
+
+ $contract_id = ScalarCoercion::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 as unpaid. Read the paid state from the fresh instance.
- $fresh = wc_get_order( $renewal_order->get_id() );
- $paid = $fresh instanceof WC_Order ? $fresh->is_paid() : $renewal_order->is_paid();
+ // 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;
- // The renewal order exists regardless of the charge outcome, so record it on the
- // cycle either way - a failed/pending cycle still references its order for dunning
- // and admin visibility.
- $cycle->set_order_id( $renewal_order->get_id() );
+ $cycle_id = (int) $cycle->get_id();
+ $read_status = $cycle->get_status()->get_value();
- if ( $paid ) {
- // CAS pending -> billed (the entity validates the transition).
+ 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( CycleStatus::billed() );
- $this->contracts->update_cycle( $cycle );
+ $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( $now );
+ $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 );
$this->contracts->update( $contract );
/**
* Fires after a renewal cycle is billed and the contract schedule advanced.
*
- * @param Contract $contract The renewed contract.
- * @param Cycle $cycle The newly-billed cycle.
- * @param WC_Order $renewal_order The paid renewal order.
+ * @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, $renewal_order );
+ do_action( self::RENEWAL_BILLED_ACTION, $contract, $cycle, $order );
return;
}
- // Not paid: settle the cycle failed and leave the contract schedule for dunning.
- $cycle->set_status( CycleStatus::failed() );
- $cycle->set_reason( 'gateway-charge-not-settled' );
- $this->contracts->update_cycle( $cycle );
+ 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( CycleStatus::failed() );
+ $cycle->set_reason( 'gateway-charge-failed' );
+ $cycle->set_claimed_until_gmt( null );
+
+ $contract->set_last_attempt_gmt( $now );
+ $this->contracts->update( $contract );
+
+ 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 );
$this->contracts->update( $contract );
}
/**
- * Build the renewal order 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 new cycle's
+ * 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
- * idempotency check can find it. Returns null (logged) when `wc_create_order()` fails.
+ * 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 int $count The chargeable number this order bills.
- * @param string $expected_total The new cycle's expected total (the price authority).
+ * @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, int $count, string $expected_total ): ?WC_Order {
+ 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' => $contract->get_customer_id(),
- 'status' => 'pending',
+ 'status' => OrderStatus::CHECKOUT_DRAFT,
'created_via' => 'woocommerce_subscriptions_engine_renewal',
)
);
@@ -557,25 +853,52 @@ final class RenewalEngine {
// shipping / tax breakdown is a later money-path's job.
$renewal_order->set_total( $expected_total );
- // Tag the renewal relation + chargeable number so the idempotency check can detect a
- // duplicate fire, and save before attaching the token so a crash between the two leaves
- // the order findable (no duplicate charge on the retry).
+ // 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();
- $token_id = $instrument->get_token_id();
- if ( null !== $token_id ) {
- $token = \WC_Payment_Tokens::get( $token_id );
- if ( $token instanceof \WC_Payment_Token ) {
- $renewal_order->add_payment_token( $token );
- }
- }
+ // 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.
*
@@ -624,10 +947,9 @@ final class RenewalEngine {
* @param Contract $contract The contract being renewed.
*/
private function attempt_charge( WC_Order $renewal_order, Contract $contract ): void {
- $gateway_id = $contract->get_payment_instrument()->get_gateway();
- if ( null === $gateway_id || '' === $gateway_id ) {
- return;
- }
+ // 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();
@@ -635,7 +957,11 @@ final class RenewalEngine {
/**
* 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.
+ * 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.
@@ -656,32 +982,48 @@ final class RenewalEngine {
}
/**
- * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists -
- * the idempotency check for AS retries.
+ * 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.
*
- * Queries on the contract id via the flat `meta_key` / `meta_value` shortcut, then
- * narrows by relation type and cycle in PHP. The flat shortcut is used rather than a
+ * 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.
+ * `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 int $contract_id Contract id.
- * @param int $cycle The cycle number the renewal would bill.
+ * @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 renewal_exists_for_cycle( int $contract_id, int $cycle ): bool {
+ 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' => 'any',
+ '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) $contract_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ '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 false;
+ return null;
}
foreach ( $orders as $order ) {
@@ -690,18 +1032,55 @@ final class RenewalEngine {
}
if ( OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
- && (string) $cycle === $order->get_meta( self::renewal_cycle_meta_key() ) ) {
- return true;
+ && (string) $cycle->get_count() === $order->get_meta( self::renewal_cycle_meta_key() ) ) {
+ return $order;
}
}
- return false;
+ 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.
+ *
+ * @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 ) {
+ return;
+ }
+
+ $contract->set_next_payment_gmt( null );
+ $this->contracts->update( $contract );
+ } 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.
*
- * Used by the per-cycle idempotency check. Internal to the engine.
+ * 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
new file mode 100644
index 00000000000..1bc5bbd5d78
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalIntent.php
@@ -0,0 +1,65 @@
+<?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
new file mode 100644
index 00000000000..0a336450272
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalNotProcessable.php
@@ -0,0 +1,29 @@
+<?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/RenewalScheduler.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php
deleted file mode 100644
index 4233936c96e..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?php
-/**
- * RenewalScheduler - the Action Scheduler bridge for renewal dispatch.
- *
- * Owns the AS hook and group conventions and keeps them behind
- * {@see RenewalEngine} so callers never couple to Action Scheduler hook names.
- * If the scheduling substrate ever changes, this is the only file that has to.
- *
- * POC shape (one job per contract): each contract has at most one pending AS
- * row, keyed by contract id. This is deliberately NOT the batch dispatcher the
- * engine targets long-term (a few recurring jobs scanning a due index with
- * lease claims) - that arrives with the cycles/attempts reshape. Until then,
- * one-job-per-contract is the simplest thing that runs the money-path.
- *
- * Integration zone: WordPress-native. Calls Action Scheduler's `as_*()`
- * functions directly.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
-
-use DateTimeImmutable;
-use DateTimeZone;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Action Scheduler bridge for renewals.
- */
-final class RenewalScheduler {
-
- /**
- * Action Scheduler hook fired when a contract's renewal is due.
- *
- * Public so tooling and tests can inspect or cancel pending actions via
- * `as_has_scheduled_action()` and friends.
- */
- public const HOOK = 'woocommerce_subscriptions_engine_process_renewal';
-
- /**
- * Action Scheduler group - used for admin filterability (Tools ->
- * Scheduled Actions) and bulk teardown.
- */
- public const GROUP = 'woocommerce_subscriptions_engine';
-
- /**
- * Enqueue an AS action for `$contract_id` at `$when`.
- *
- * Does NOT clear pre-existing pending actions - {@see RenewalEngine::schedule()}
- * owns the clear-then-enqueue that keeps the single-row-per-contract
- * invariant. Calling this directly without first unscheduling will produce
- * duplicate rows.
- *
- * `$when->getTimestamp()` is a UTC unix timestamp regardless of the
- * argument's timezone, so the dispatch moment is unambiguous.
- *
- * @param int $contract_id Contract whose renewal is being scheduled.
- * @param DateTimeImmutable $when When the renewal should fire.
- */
- public static function schedule( int $contract_id, DateTimeImmutable $when ): void {
- as_schedule_single_action(
- $when->getTimestamp(),
- self::HOOK,
- array( $contract_id ),
- self::GROUP
- );
- }
-
- /**
- * Cancel any pending AS action for `$contract_id`.
- *
- * AS matches on hook + args + group, so passing `[ $contract_id ]` scopes
- * the cancel to that one contract. No-op when nothing is pending.
- *
- * @param int $contract_id Contract whose pending renewal to clear.
- */
- public static function unschedule( int $contract_id ): void {
- as_unschedule_all_actions( self::HOOK, array( $contract_id ), self::GROUP );
- }
-
- /**
- * Whether a renewal action is currently pending for `$contract_id`.
- *
- * @param int $contract_id Contract to query.
- */
- public static function is_scheduled( int $contract_id ): bool {
- return false !== as_next_scheduled_action( self::HOOK, array( $contract_id ), self::GROUP );
- }
-
- /**
- * The moment the next pending renewal will fire for `$contract_id`, or null
- * if nothing is queued.
- *
- * AS returns an int unix timestamp (UTC) for a pending action, or
- * `false` / `0` when nothing future is queued.
- *
- * @param int $contract_id Contract to query.
- * @return DateTimeImmutable|null UTC moment of the next renewal, or null.
- */
- public static function next_scheduled( int $contract_id ): ?DateTimeImmutable {
- $timestamp = as_next_scheduled_action( self::HOOK, array( $contract_id ), self::GROUP );
- if ( ! is_int( $timestamp ) || $timestamp <= 0 ) {
- return null;
- }
-
- return ( new DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new DateTimeZone( 'UTC' ) );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php
new file mode 100644
index 00000000000..622ac738ff6
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalSelector.php
@@ -0,0 +1,120 @@
+<?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 unparseable 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 a158749ac14..4aa70978438 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -6,7 +6,7 @@
* 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_current_cycle()}, {@see self::max_count()},
+ * through purpose-built reads ({@see self::find_chain_head()}, {@see self::max_count()},
* 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}.
@@ -18,7 +18,10 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+use DateTimeImmutable;
+use DateTimeZone;
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\Support\ScalarCoercion;
@@ -51,6 +54,11 @@ final class ContractRepository {
'phone',
);
+ /**
+ * Logger source tag.
+ */
+ private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
/**
* The per-contract typed snapshot store.
*
@@ -248,6 +256,95 @@ final class ContractRepository {
return $contracts;
}
+ /**
+ * Contracts actionable for renewal at `$now`, oldest-due first - the batch dispatcher's scan.
+ * Active, primitive-scheduled contracts whose `next_payment_gmt` has arrived, joined to their
+ * head cycle so the scan can filter to the ones actually chargeable now:
+ *
+ * - head `billed`/`cancelled` and its period has ended (`ends_at_gmt <= now`) -> advance-ready;
+ * - head `pending` with an expired crash-recovery lease (`claimed_until <= now`) -> reclaim-ready.
+ *
+ * A head that is `failed` (awaits dunning), `processing` (awaits its gateway), or `pending`
+ * with a live lease is deliberately excluded. Because that filter is in SQL, `LIMIT` counts
+ * only actionable rows, so a cluster of non-actionable heads (a stuck gateway, a backlog of
+ * declines) cannot occupy the batch and starve healthy renewals behind them. Gateway-scheduled
+ * contracts are excluded (the gateway owns their renewal); a null `next_payment_gmt` never
+ * matches the `<=` comparison. Driven by the `due_contract (status, next_payment_gmt)` index;
+ * the head cycle is joined per candidate via the `chain_seq` UNIQUE index. Returns the head
+ * fields selection needs, so the dispatcher does not re-load the head to decide what to bill.
+ *
+ * @param DateTimeImmutable $now The cutoff moment; contracts due at or before it.
+ * @param int $limit Maximum rows to return (the batch size).
+ * @return array<int, RenewalCandidate> Actionable renewal candidates, oldest-due first.
+ */
+ public function find_due( DateTimeImmutable $now, int $limit ): array {
+ if ( $limit < 1 ) {
+ return array();
+ }
+
+ global $wpdb;
+
+ $contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ $cycles = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $cutoff = $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+
+ // Table names cannot be bound, so they are interpolated; every value is a placeholder.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $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.status = %s AND c.schedule_source <> %s AND c.next_payment_gmt IS NOT NULL AND c.next_payment_gmt <= %s
+ AND (
+ ( cy.status = %s AND cy.ends_at_gmt <= %s )
+ OR ( cy.status = %s AND cy.claimed_until IS NOT NULL AND cy.claimed_until <= %s )
+ )
+ ORDER BY c.next_payment_gmt ASC, c.id ASC
+ LIMIT %d",
+ Cycle::KIND_BILLING,
+ Cycle::KIND_BILLING,
+ ContractStatus::ACTIVE,
+ Contract::SCHEDULE_SOURCE_GATEWAY,
+ $cutoff,
+ CycleStatus::BILLED,
+ $cutoff,
+ CycleStatus::PENDING,
+ $cutoff,
+ $limit
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ // A failed scan otherwise reads exactly like "nothing due" and renewals stall
+ // 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 ),
+ array( 'source' => self::LOG_SOURCE )
+ );
+ }
+
+ $result = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ if ( ! is_array( $row ) ) {
+ continue;
+ }
+ $head_count = $row['head_count'] ?? null;
+ $result[] = new RenewalCandidate(
+ ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 ),
+ null === $head_count ? null : ScalarCoercion::coerce_int( $head_count ),
+ ScalarCoercion::coerce_string( $row['head_status'] ?? '' ),
+ ScalarCoercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
+ );
+ }
+
+ return $result;
+ }
+
/**
* Whether a contract row exists for `$id`.
*
@@ -302,8 +399,10 @@ final class ContractRepository {
* @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.
- * @throws \RuntimeException If a snapshot or cycle write fails (e.g. a duplicate
- * (contract_id, kind, sequence_no) the UNIQUE index rejects).
+ * @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).
*/
public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
$this->resolve_cycle_snapshots( $cycle, $previous );
@@ -343,15 +442,100 @@ final class ContractRepository {
}
/**
- * The chain's most-recent cycle (highest `sequence_no` in `(contract_id, kind)`),
- * or null when the chain is empty. Snapshots are decoded into typed value objects
- * only for an in-flight cycle (see {@see self::hydrate_cycle()}).
+ * 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).
+ */
+ public function reclaim_expired_cycle( int $cycle_id, int $lease_ttl_seconds ): bool {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // 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 ) );
+
+ // 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;
+ }
+
+ /**
+ * 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;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // 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 ) );
+
+ return false !== $result && 1 === $wpdb->rows_affected;
+ }
+
+ /**
+ * 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.
+ *
+ * @param int $cycle_id The cycle to settle.
+ * @param string $from_status The status the caller read; the CAS predicate.
+ * @param string $to_status The settled status to write.
+ * @param int $order_id The renewal order carrying the outcome.
+ * @param string|null $reason Failure reason to record, or null to clear.
+ */
+ public function transition_cycle_status( int $cycle_id, string $from_status, string $to_status, int $order_id, ?string $reason = null ): bool {
+ global $wpdb;
+
+ $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 ) );
+ }
+
+ return false !== $result && 1 === $wpdb->rows_affected;
+ }
+
+ /**
+ * 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()}).
*
* @param int $contract_id Contract id.
* @param string $kind Chain kind. Defaults to billing.
- * @return Cycle|null The most-recent cycle, or null if the chain has none.
+ * @return Cycle|null The head cycle, or null if the chain has none.
*/
- public function find_current_cycle( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
+ public function find_chain_head( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
global $wpdb;
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
@@ -543,7 +727,8 @@ final class ContractRepository {
* Insert a cycle row and stamp the generated id back onto the entity.
*
* @param Cycle $cycle Cycle to insert. Carries its contract id and kind.
- * @throws \RuntimeException If the cycle insert fails.
+ * @throws DuplicateCycleException If a chain UNIQUE index rejects the row.
+ * @throws \RuntimeException If the insert fails for any other reason.
*/
private function insert_cycle( Cycle $cycle ): void {
global $wpdb;
@@ -563,7 +748,12 @@ final class ContractRepository {
);
if ( false === $inserted ) {
- throw new \RuntimeException( 'Failed to insert cycle.' );
+ $db_error = $wpdb->last_error;
+ if ( false !== stripos( $db_error, 'Duplicate entry' ) ) {
+ // A chain UNIQUE index rejected the row: the position is already claimed.
+ throw new DuplicateCycleException( esc_html( 'Cycle position already exists: ' . $db_error ) );
+ }
+ throw new \RuntimeException( esc_html( 'Failed to insert cycle. ' . $db_error ) );
}
$cycle->set_id( (int) $wpdb->insert_id );
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
new file mode 100644
index 00000000000..544c6219c4d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/DuplicateCycleException.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * DuplicateCycleException - a cycle insert rejected by the chain's UNIQUE indexes
+ * (`(contract_id, kind, count)` / `(contract_id, kind, sequence_no)`): the position is
+ * already taken. This is the expected create-as-claim race signal, distinguished from
+ * any other write failure so callers can treat the collision as benign without masking
+ * real database errors.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use RuntimeException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown when a cycle insert collides with an existing chain row.
+ */
+final class DuplicateCycleException extends RuntimeException {
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php
new file mode 100644
index 00000000000..1fad9fb5604
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/RenewalCandidate.php
@@ -0,0 +1,117 @@
+<?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 5a8f3b55e5d..036096ab2b0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -34,13 +34,16 @@ 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
+ * reserved `retry_at`; a `due_contract (status, next_payment_gmt)` index on
+ * contracts for the batch renewal scan.
*
* 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.1.1';
+ private const VERSION = '2.2.0';
/**
* Option key tracking the installed schema version.
@@ -234,12 +237,14 @@ final class SchemaInstaller {
) {$collate};";
// The contract row is the live source of truth: the totals and stamps are live
- // values, not caches of cycles. The `due` index keys the renewal scan off
- // `next_payment_gmt`. `origin_order_id` is NULLABLE (a manual/admin contract has
- // no origin order). There is no generic `cycle_count` - counters are per-chain,
- // derived as `MAX(count)` over `(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.
+ // values, not caches of cycles. The `due_contract (status, next_payment_gmt)` index
+ // keys the batch dispatcher's scan (status equality, then a range on the due date);
+ // `due` is retained for next-bill-cache lookups keyed the other way. `origin_order_id`
+ // is NULLABLE (a manual/admin contract has no origin order). There is no generic
+ // `cycle_count` - counters are per-chain, derived as `MAX(count)` over
+ // `(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.
$contracts_sql = "CREATE TABLE {$contracts} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
status VARCHAR(20) NOT NULL,
@@ -269,6 +274,7 @@ final class SchemaInstaller {
PRIMARY KEY (id),
KEY customer_status (customer_id, status),
KEY due (next_payment_gmt, status),
+ KEY due_contract (status, next_payment_gmt),
KEY origin_order (origin_order_id),
KEY extension_slug (extension_slug)
) {$collate};";
@@ -324,7 +330,10 @@ final class SchemaInstaller {
// 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).
+ // 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.
+ // `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,
contract_id BIGINT UNSIGNED NOT NULL,
@@ -341,6 +350,8 @@ final class SchemaInstaller {
items_snapshot_id BIGINT UNSIGNED NULL,
order_id BIGINT UNSIGNED NULL,
extension_slug VARCHAR(64) NULL,
+ claimed_until DATETIME NULL,
+ retry_at DATETIME NULL,
date_created_gmt DATETIME NOT NULL,
date_updated_gmt DATETIME NOT NULL,
PRIMARY KEY (id),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index de289b3331d..27ddf59396c 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -57,4 +57,31 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
$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.
+ *
+ * @param string $gateway Gateway id to fail charges for.
+ */
+ protected function fail_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->update_status( 'failed', 'Gateway declined the recurring charge.' );
+ }
+ },
+ 10,
+ 2
+ );
+
+ $this->approved_gateways[] = $gateway;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
index 1bc78f3fadf..a363b35c0da 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
@@ -119,7 +119,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$this->assertNotNull( $contract_id );
$repo = new ContractRepository();
- $cycle = $repo->find_current_cycle( $contract_id );
+ $cycle = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
@@ -202,7 +202,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$this->assertNotNull( $contract_id );
// Cycle 1's period end matches that first renewal date.
- $cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( '2026-01-29 00:00:00', $cycle->get_ends_at_gmt() );
}
@@ -229,7 +229,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- $cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( '49.00000000', $cycle->get_expected_total() );
$this->assertSame( '2026-12-01 00:00:00', $cycle->get_ends_at_gmt() );
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
new file mode 100644
index 00000000000..9a16e282de1
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -0,0 +1,366 @@
+<?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\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @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 to open the processing gate in charging tests.
+ */
+ private const CONSUMER = 'engine-tests-consumer';
+
+ 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' ) );
+ }
+
+ /**
+ * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+ */
+ private function make_plan_object(): Plan {
+ $group_id = ( new PlanGroupRepository() )->insert( PlanGroup::create( array( 'name' => 'Club' ) ) );
+
+ $plan = Plan::create(
+ $group_id,
+ array(
+ 'name' => 'Monthly',
+ 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+ 'category' => Plan::DEFAULT_CATEGORY,
+ 'extension_slug' => 'engine-tests',
+ )
+ );
+ ( new PlanRepository() )->insert( $plan );
+
+ return $plan;
+ }
+
+ /**
+ * Sign up a contract via the checkout factory 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->make_plan_object();
+
+ $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();
+
+ $contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+
+ // The factory anchors the first renewal off the plan cadence; pin the schedule date
+ // the test reasons about so due/not-due is explicit.
+ $contract->set_next_payment_gmt( $next_payment_gmt );
+ ( new ContractRepository() )->update( $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, $repo->max_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( 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, $repo->max_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, $repo->max_count( (int) $first->get_id() ) );
+ $this->assertSame( 2, $repo->max_count( (int) $second->get_id() ) );
+ $this->assertSame( 1, $repo->max_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, $repo->max_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, ( new ContractRepository() )->max_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( 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' );
+ }
+ )
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
index 49c882c98eb..7cb95c4d2b7 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
@@ -1,6 +1,6 @@
<?php
/**
- * Integration tests for RenewalEngine (and RenewalScheduler).
+ * Integration tests for RenewalEngine.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -22,15 +22,16 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalIntent;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler
*/
class RenewalEngineTest extends EngineIntegrationTestCase {
@@ -43,21 +44,68 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
private const GATEWAY_APPROVING = 'engine_test_gateway_approve';
/**
- * A gateway that declares `recurring` but never completes the charge, so the
- * renewal order stays unpaid (the failed-charge path).
+ * 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;
+ }
+
private function make_plan( ?int $max_cycles = null ): int {
return (int) $this->make_plan_object( $max_cycles )->get_id();
}
@@ -137,66 +185,16 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox schedule is gated on the gateway's recurring capability.
- */
- public function test_schedule_is_gated_on_recurring_capability(): void {
- $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 );
-
- $engine = new RenewalEngine();
-
- // No capability declared: scheduling is refused.
- $this->assertFalse( $engine->schedule( $contract ) );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
-
- // Declare it and the schedule sticks.
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
- $this->assertTrue( $engine->schedule( $contract ) );
- $this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
- }
-
- /**
- * @testdox schedule replaces any existing pending row (one row per contract).
+ * @testdox the scheduled scan creates a renewal order tagged for the next chargeable number.
*/
- public function test_schedule_replaces_existing_row(): 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 );
-
- $engine = new RenewalEngine();
- $engine->schedule( $contract );
- $engine->schedule( $contract );
-
- // Exactly one pending row for the contract.
- $pending = as_get_scheduled_actions(
- array(
- 'hook' => RenewalScheduler::HOOK,
- 'args' => array( $contract_id ),
- 'status' => \ActionScheduler_Store::STATUS_PENDING,
- ),
- 'ids'
- );
- $this->assertCount( 1, $pending );
- }
-
- /**
- * @testdox process_due creates a renewal order tagged for the next chargeable number.
- */
- public function test_process_due_creates_renewal_order(): void {
+ 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 = ( new RenewalEngine() )->process_due( $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 );
@@ -209,39 +207,37 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox process_due skips when a renewal order is already tagged for the cycle.
+ * @testdox the scheduled scan does not re-select a failed cycle (no second order).
*
- * Covers the order-meta pre-check: the first run tags a renewal order for the cycle, so
- * the retried due action is suppressed before a second cycle/order is created.
+ * 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_process_due_skips_when_a_renewal_order_is_already_tagged(): void {
- GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+ 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 );
- $engine = new RenewalEngine();
- $first = $engine->process_due( $contract_id );
+ $first = $this->run_scheduled_renewal( $contract_id );
$this->assertInstanceOf( WC_Order::class, $first );
- // The charge did not settle, so the head stays at the same chargeable number; a retried
- // due action is suppressed by the order-meta pre-check rather than creating a second order.
- $retry = $engine->process_due( $contract_id );
+ // 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 process_due skips when the cycle is already claimed (create-as-claim UNIQUE).
+ * @testdox the scheduled scan skips when the head cycle is a live pending claim.
*
- * Covers the claim_next_cycle catch path: a pending cycle for the target count exists with
- * NO tagged renewal order, so the order-meta pre-check finds nothing and process_due reaches
- * the append_cycle insert, which loses the UNIQUE(contract_id, kind, count) race. It returns
- * null and creates no duplicate cycle or order.
+ * 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_process_due_skips_when_the_cycle_is_already_claimed(): void {
+ 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 );
@@ -251,7 +247,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// 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_current_cycle( $contract_id );
+ $previous = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $previous );
$claimed = Cycle::create(
array(
@@ -267,7 +263,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
);
$repo->append_cycle( $claimed, $previous );
- $result = ( new RenewalEngine() )->process_due( $contract_id );
+ $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.
@@ -283,16 +279,107 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox process_due advances the chain: cycle 2 billed, order linked, schedule moved.
+ * @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( 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_process_due_advances_the_chain_on_a_successful_charge(): void {
+ 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 );
- $renewal_order = ( new RenewalEngine() )->process_due( $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( 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.
@@ -301,7 +388,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
$repo = new ContractRepository();
- $cycle = $repo->find_current_cycle( $contract_id );
+ $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 );
@@ -321,9 +408,9 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox process_due builds the renewal order from the contract's own line items and addresses.
+ * @testdox the scheduled scan builds the renewal order from the contract's own line items and addresses.
*/
- public function test_process_due_builds_renewal_from_contract_items_and_addresses(): void {
+ 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();
@@ -366,7 +453,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- $renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+ $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.
@@ -385,23 +472,23 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox process_due on a failed charge marks the cycle failed and leaves the schedule.
+ * @testdox the scheduled scan on a declined charge marks the cycle failed and leaves the schedule.
*/
- public function test_process_due_marks_the_cycle_failed_when_the_charge_does_not_settle(): void {
- // Declared recurring, but no handler completes the charge: the order stays unpaid.
- GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+ 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 = ( new RenewalEngine() )->process_due( $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_current_cycle( $contract_id );
+ $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).
@@ -422,27 +509,26 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox process_due retry of an unsettled cycle adds no duplicate cycle/order.
+ * @testdox the scheduled scan retry of a failed cycle adds no duplicate cycle/order.
*
- * A failed charge leaves cycle 2 `failed` with its order (and the schedule unchanged).
- * Re-firing targets the same count, so the order-meta pre-check makes it 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.)
+ * 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_process_due_retry_of_an_unsettled_cycle_is_idempotent(): void {
- GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+ 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 );
- $engine = new RenewalEngine();
- $first = $engine->process_due( $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 unsettled (failed) creates no duplicate.
- $second = $engine->process_due( $contract_id );
+ // 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 ) );
@@ -459,13 +545,13 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * @testdox process_due falls back to the live plan cadence when the cycle carries no snapshot.
+ * @testdox the scheduled scan falls back to the live plan cadence when the cycle carries no snapshot.
*
- * After cycle 1 is billed (terminal) find_current_cycle() hydrates it WITHOUT its 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_process_due_falls_back_to_live_plan_when_cycle_has_no_snapshot(): void {
+ 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 );
@@ -475,11 +561,11 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// 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_current_cycle( $contract_id );
+ $head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertNull( $head->get_plan_snapshot() );
- $renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+ $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).
@@ -487,45 +573,195 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
- $cycle = $repo->find_current_cycle( $contract_id );
+ $cycle = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
}
/**
- * @testdox process_due skips a contract that has no billing chain to advance.
+ * @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. process_due must skip (return null) rather than silently bill it as
- * cycle 1 or throw - a thrown error would make a scheduled action retry forever.
+ * 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_process_due_skips_a_contract_with_no_billing_chain(): void {
+ 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.
+ // 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 = ( new RenewalEngine() )->process_due( $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_current_cycle( $contract_id ) );
+ $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' => 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 process_due renews from the contract's own plan snapshot even when the live plan is deleted.
+ * @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( 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( 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 renews from the contract's own plan snapshot even when the live plan is deleted.
*
* The contract's frozen snapshot is the cadence source of truth, so a deleted live selling
* plan no longer blocks the renewal - the chain advances on the snapshot's terms.
*/
- public function test_process_due_renews_from_contract_snapshot_when_live_plan_deleted(): void {
+ public function test_scheduled_renewal_renews_from_contract_snapshot_when_live_plan_deleted(): void {
$this->approve_charges_for( self::GATEWAY_APPROVING );
$contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
@@ -535,27 +771,27 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// Delete the live selling plan; the contract keeps its frozen snapshot.
( new PlanRepository() )->delete( $contract->get_selling_plan_id() );
- $renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+ $renewal_order = $this->run_scheduled_renewal( $contract_id );
$this->assertInstanceOf( WC_Order::class, $renewal_order );
// Cycle 2 was billed from the snapshot's cadence.
- $cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $cycle );
$this->assertSame( 2, $cycle->get_count() );
$this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
}
/**
- * @testdox process_due expires the contract when it hits max cycles.
+ * @testdox the scheduled scan expires the contract when it hits max cycles.
*/
- public function test_process_due_expires_contract_at_max_cycles(): void {
+ public function test_scheduled_renewal_expires_contract_at_max_cycles(): void {
$this->markTestSkipped( 'Max-cycle expiry lands with the dispatcher.' );
}
/**
- * @testdox process_due skips a non-active contract and creates no renewal order.
+ * @testdox the scheduled scan skips a non-active contract and creates no renewal order.
*/
- public function test_process_due_skips_non_active_contract(): void {
+ public function test_scheduled_renewal_skips_non_active_contract(): void {
GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
$plan_id = $this->make_plan();
@@ -566,15 +802,15 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$contract->set_status( ContractStatus::ON_HOLD );
( new ContractRepository() )->update( $contract );
- $this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
+ $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
}
/**
- * @testdox process_due skips a gateway-scheduled contract and creates no renewal order.
+ * @testdox the scheduled scan skips a gateway-scheduled contract and creates no renewal order.
*/
- public function test_process_due_skips_gateway_scheduled_contract(): void {
+ public function test_scheduled_renewal_skips_gateway_scheduled_contract(): void {
GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
$plan_id = $this->make_plan();
@@ -594,21 +830,24 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$contract_id = ( new ContractRepository() )->insert( $contract );
// Active, but the gateway owns the schedule: the primitive path bails.
- $this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
+ $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
$this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
}
/**
- * @testdox process_due skips an unknown contract.
+ * @testdox the scheduled scan skips an unknown contract.
*/
- public function test_process_due_skips_unknown_contract(): void {
- $this->assertNull( ( new RenewalEngine() )->process_due( 999999 ) );
+ public function test_scheduled_renewal_skips_unknown_contract(): void {
+ $this->assertNull( $this->run_scheduled_renewal( 999999 ) );
}
/**
- * @testdox cancel transitions the contract to cancelled and clears its pending row.
+ * @testdox cancel transitions the contract to cancelled.
+ *
+ * The due scan only selects active contracts, so cancellation needs no schedule
+ * cleanup - the status transition alone removes the contract from renewal.
*/
- public function test_cancel_transitions_and_unschedules(): void {
+ public function test_cancel_transitions_the_contract(): void {
GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
$plan_id = $this->make_plan();
@@ -617,16 +856,11 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- $engine = new RenewalEngine();
- $engine->schedule( $contract );
- $this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
-
$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
$reloaded = ( new ContractRepository() )->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
/**
@@ -639,7 +873,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// Append a pending cycle 2 (a charge caught mid-flight).
$repo = new ContractRepository();
- $previous = $repo->find_current_cycle( $contract_id );
+ $previous = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $previous );
$pending = Cycle::create(
array(
@@ -662,7 +896,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
- $head = $repo->find_current_cycle( $contract_id );
+ $head = $repo->find_chain_head( $contract_id );
$this->assertInstanceOf( Cycle::class, $head );
$this->assertTrue( $head->get_status()->equals( CycleStatus::cancelled() ) );
}
@@ -678,35 +912,641 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
// Cycle 1 stays billed (only a pending head is closed by cancel).
- $head = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $head = ( new ContractRepository() )->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ }
+
+
+ /**
+ * @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( 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( 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( 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( 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( 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( CycleStatus::billed() ) );
}
/**
- * @testdox A gateway-scheduled contract is not scheduled by the engine.
+ * @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( 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_gateway_scheduled_contract_is_not_scheduled(): void {
+ 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( 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( 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 = Contract::create(
+ $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( 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( 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( 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( 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( 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() );
+ }
+
+ /**
+ * @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( CycleStatus::processing() ) );
+
+ // An admin marks the order paid by hand; the status-transition listener settles.
+ $renewal_order->update_status( 'processing' );
+
+ $settled = $repo->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $settled );
+ $this->assertTrue( $settled->get_status()->equals( CycleStatus::billed() ), 'The manual paid transition bills the cycle.' );
+
+ // 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() );
+ }
+
+ /**
+ * 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(
- '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' => $contract_id,
+ 'sequence_no' => $previous->get_sequence_no() + 1,
+ 'count' => 2,
+ 'status' => 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,
)
);
- $contract_id = ( new ContractRepository() )->insert( $contract );
+ ( 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 );
+ }
- $this->assertFalse( ( new RenewalEngine() )->schedule( $contract ) );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
+ $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;
}
/**
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 f78303d8290..d04f8a4d35a 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
@@ -17,6 +17,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
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;
/**
@@ -267,7 +269,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( $cycle->get_items_snapshot_id(), $reloaded->get_items_snapshot_id() );
// Cycle 1 is the billed signup, reachable as the chain's most-recent cycle.
- $current = $this->sut->find_current_cycle( $id );
+ $current = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $current );
$this->assertSame( 1, $current->get_count() );
$this->assertTrue( $current->get_status()->equals( CycleStatus::billed() ) );
@@ -412,7 +414,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
/**
* @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
*/
- public function test_append_cycle_and_find_current_cycle(): void {
+ 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() );
@@ -420,7 +422,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertNotNull( $cycle->get_id() );
- $current = $this->sut->find_current_cycle( $id );
+ $current = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $current );
$this->assertSame( $cycle->get_id(), $current->get_id() );
$this->assertSame( 1, $current->get_sequence_no() );
@@ -464,15 +466,15 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
);
$this->sut->append_cycle( $cycle );
- $reloaded = $this->sut->find_current_cycle( $id );
+ $reloaded = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $reloaded );
$this->assertSame( '9.12345678', $reloaded->get_expected_total() );
}
/**
- * @testdox find_current_cycle returns the highest-sequence cycle in the chain.
+ * @testdox find_chain_head returns the highest-sequence cycle in the chain.
*/
- public function test_find_current_cycle_returns_the_head(): void {
+ public function test_find_chain_head_returns_the_head(): void {
$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' );
@@ -481,18 +483,18 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$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 );
- $current = $this->sut->find_current_cycle( $id );
+ $current = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $current );
$this->assertSame( 2, $current->get_sequence_no() );
}
/**
- * @testdox find_current_cycle returns null for a chain with no cycles.
+ * @testdox find_chain_head returns null for a chain with no cycles.
*/
- public function test_find_current_cycle_is_null_when_empty(): void {
+ public function test_find_chain_head_is_null_when_empty(): void {
$id = $this->sut->insert( $this->make_contract() );
- $this->assertNull( $this->sut->find_current_cycle( $id ) );
+ $this->assertNull( $this->sut->find_chain_head( $id ) );
}
/**
@@ -577,11 +579,374 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$cycle->set_status( CycleStatus::billed() );
$this->sut->update_cycle( $cycle );
- $reloaded = $this->sut->find_current_cycle( $id );
+ $reloaded = $this->sut->find_chain_head( $id );
$this->assertInstanceOf( Cycle::class, $reloaded );
$this->assertTrue( $reloaded->get_status()->equals( CycleStatus::billed() ) );
}
+ /**
+ * @testdox The cycle crash-recovery lease column round-trips through append and update.
+ */
+ 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' => 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 );
+
+ $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( CycleStatus::billed() );
+ $reloaded->set_claimed_until_gmt( null );
+ $this->sut->update_cycle( $reloaded );
+
+ $settled = $this->sut->find_chain_head( $id );
+ $this->assertInstanceOf( Cycle::class, $settled );
+ $this->assertNull( $settled->get_claimed_until_gmt() );
+ }
+
+ /**
+ * @testdox reclaim_expired_cycle wins the CAS for an expired-lease pending cycle and extends the lease.
+ *
+ * The expiry predicate and the fresh lease both anchor on the database clock, so the
+ * test seeds the lease 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 ) );
+
+ // 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.' );
+
+ $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.' );
+ }
+
+ /**
+ * @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.
+ */
+ public function test_reclaim_expired_cycle_arbitrates_the_race(): 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_id = (int) $cycle->get_id();
+
+ // 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.' );
+
+ // 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.' );
+
+ // 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' ) );
+ }
+
+ /**
+ * @testdox reclaim_expired_cycle does not touch a settled (non-pending) cycle.
+ */
+ 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( CycleStatus::billed() );
+ $cycle->set_claimed_until_gmt( null );
+ $this->sut->update_cycle( $cycle );
+
+ $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.' );
+ }
+
+ /**
+ * Append a pending cycle 1 carrying a crash-recovery lease, returning it (with its id).
+ *
+ * @param int $contract_id Contract id.
+ * @param string $claimed_until The lease expiry GMT string to stamp.
+ */
+ private function append_pending_cycle_with_lease( int $contract_id, string $claimed_until ): Cycle {
+ $cycle = Cycle::create(
+ array(
+ 'contract_id' => $contract_id,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'status' => 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' => $claimed_until,
+ )
+ );
+ $this->sut->append_cycle( $cycle );
+
+ return $cycle;
+ }
+
+ /**
+ * @testdox find_due returns only active contracts whose next_payment has arrived, oldest first.
+ */
+ 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' ) );
+
+ $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 );
+
+ $ids = $this->due_ids( $now, 50 );
+
+ // Only the two due+active contracts, oldest-due first; the future and the non-active excluded.
+ $this->assertSame( array( $due_old, $due_recent ), $ids );
+ $this->assertNotContains( $not_yet, $ids );
+ $this->assertNotContains( $on_hold, $ids );
+ }
+
+ /**
+ * @testdox find_due treats the cutoff as inclusive and excludes a null next_payment.
+ */
+ public function test_find_due_is_inclusive_and_skips_null_schedule(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+ $exactly_due = $this->insert_contract_due_at( '2026-07-15 00:00:00', ContractStatus::ACTIVE );
+ $no_schedule = $this->insert_contract_due_at( null, ContractStatus::ACTIVE );
+
+ $ids = $this->due_ids( $now, 50 );
+
+ $this->assertContains( $exactly_due, $ids, 'A contract due exactly at the cutoff is included.' );
+ $this->assertNotContains( $no_schedule, $ids, 'A contract with no next_payment_gmt is never due.' );
+ }
+
+ /**
+ * @testdox find_due caps the batch at the limit from the oldest-due end.
+ */
+ public function test_find_due_honours_limit(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+ $first = $this->insert_contract_due_at( '2026-05-15 00:00:00', ContractStatus::ACTIVE );
+ $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).
+ */
+ public function test_find_due_excludes_gateway_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 );
+
+ $ids = $this->due_ids( $now, 50 );
+
+ $this->assertContains( $primitive, $ids );
+ $this->assertNotContains( $gateway, $ids, 'A gateway-scheduled contract must never enter the primitive scan.' );
+ }
+
+ /**
+ * @testdox find_due returns the head fields the selector needs.
+ */
+ public function test_find_due_returns_the_head_fields(): 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 );
+ $candidates = $this->sut->find_due( $now, 50 );
+
+ $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() );
+ }
+
+ /**
+ * @testdox find_due excludes a contract whose head is not actionable (failed / processing).
+ */
+ public function test_find_due_excludes_non_actionable_heads(): void {
+ $now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
+
+ $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 );
+
+ $ids = $this->due_ids( $now, 50 );
+
+ $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.' );
+ }
+
+ /**
+ * @testdox find_due excludes a billed head whose period has not ended (the advance due-guard).
+ */
+ 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' ) );
+
+ // 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->assertNotContains( $not_ended, $this->due_ids( $now, 50 ) );
+ }
+
+ /**
+ * @testdox find_due includes a reclaim-ready pending head but not a live-lease one.
+ */
+ 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' ) );
+
+ $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' );
+
+ $ids = $this->due_ids( $now, 50 );
+
+ $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.' );
+ }
+
+ /**
+ * @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->assertSame( array(), $this->sut->find_due( $now, 0 ) );
+ $this->assertSame( array(), $this->sut->find_due( $now, -1 ) );
+ }
+
+ /**
+ * @testdox transition_cycle_status settles atomically: one caller wins, repeats lose.
+ */
+ 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' );
+
+ $head = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $cycle_id = (int) $head->get_id();
+
+ // 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 ) );
+
+ $settled = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $settled );
+ $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() );
+
+ // 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' ) );
+
+ $after = $this->sut->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $after );
+ $this->assertSame( CycleStatus::BILLED, $after->get_status()->get_value(), 'The losing transition writes nothing.' );
+ $this->assertSame( 4242, $after->get_order_id() );
+ }
+
+ /**
+ * The contract ids of the due scan at `$now`, in scan order.
+ *
+ * @param \DateTimeImmutable $now The cutoff moment.
+ * @param int $limit The batch size.
+ * @return array<int, int>
+ */
+ private function due_ids( \DateTimeImmutable $now, int $limit ): array {
+ return array_map(
+ static function ( RenewalCandidate $candidate ): int {
+ return $candidate->get_contract_id();
+ },
+ $this->sut->find_due( $now, $limit )
+ );
+ }
+
+ /**
+ * Insert a contract with the given schedule date and status, plus a head cycle so the
+ * cycle-aware scan can join it. The head is billed and ends when the next payment is due
+ * (advance-ready) unless overridden. Returns the contract id.
+ *
+ * @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 $head_status The head cycle status (a CycleStatus value).
+ * @param string|null $claimed_until The head cycle lease expiry, or null for none.
+ * @param string|null $head_ends_at The head period end; defaults to `$next_payment_gmt`.
+ */
+ private function insert_contract_due_at(
+ ?string $next_payment_gmt,
+ string $status,
+ string $schedule_source = Contract::SCHEDULE_SOURCE_PRIMITIVE,
+ string $head_status = CycleStatus::BILLED,
+ ?string $claimed_until = null,
+ ?string $head_ends_at = null
+ ): int {
+ $contract = Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 7,
+ 'origin_order_id' => 1001,
+ 'start_gmt' => '2026-01-15 00:00:00',
+ 'next_payment_gmt' => $next_payment_gmt,
+ 'status' => $status,
+ 'schedule_source' => $schedule_source,
+ )
+ );
+ $id = $this->sut->insert( $contract );
+
+ if ( null !== $next_payment_gmt ) {
+ $this->sut->append_cycle(
+ Cycle::create(
+ array(
+ 'contract_id' => $id,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'status' => CycleStatus::from( $head_status ),
+ 'starts_at_gmt' => '2026-01-15 00:00:00',
+ 'ends_at_gmt' => $head_ends_at ?? $next_payment_gmt,
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'claimed_until' => $claimed_until,
+ )
+ )
+ );
+ }
+
+ return $id;
+ }
+
/**
* @testdox Consecutive cycles with an unchanged plan/items share one snapshot row each.
*/
@@ -705,6 +1070,20 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertFalse( $inserted, 'A duplicate (contract_id, kind, count) must be rejected by the UNIQUE index.' );
}
+ /**
+ * @testdox append_cycle surfaces a UNIQUE collision as DuplicateCycleException.
+ *
+ * The create-as-claim race signal must be distinguishable from any other write failure,
+ * so the money-path can treat the collision as benign without masking real errors.
+ */
+ public function test_append_cycle_throws_duplicate_cycle_exception_on_collision(): void {
+ $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->expectException( DuplicateCycleException::class );
+ $this->sut->append_cycle( $this->make_cycle( $id, 2, 1, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+ }
+
/**
* @testdox Multiple non-counting cycles (count = null) coexist in one chain.
*/
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 43b54a7bcb9..33fe09f1c08 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
@@ -234,6 +234,21 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
);
}
+ /**
+ * @testdox The due_contract index keys the dispatcher scan as (status, next_payment_gmt).
+ */
+ public function test_contracts_due_contract_index_keys_the_dispatcher_scan(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // The dispatcher scans status=active AND next_payment_gmt <= now; the status-first
+ // column order is load-bearing for the index, so assert it exactly.
+ $this->assertContains( 'due_contract', $this->index_names( $table ) );
+ $this->assertSame(
+ array( 'status', 'next_payment_gmt' ),
+ $this->index_columns( $table, 'due_contract' )
+ );
+ }
+
public function test_cycles_table_has_expected_columns(): void {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
@@ -253,6 +268,8 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
'items_snapshot_id',
'order_id',
'extension_slug',
+ 'claimed_until',
+ 'retry_at',
);
foreach ( $columns as $column ) {
@@ -260,6 +277,25 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
}
}
+ /**
+ * @testdox The cycle dispatcher columns (claimed_until lease, reserved retry_at) are nullable.
+ */
+ public function test_cycles_table_dispatcher_columns_are_nullable(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ foreach ( array( 'claimed_until', 'retry_at' ) as $column ) {
+ // 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", $column ), ARRAY_A );
+
+ $this->assertIsArray( $row, "Expected a cycles.{$column} column." );
+ $this->assertArrayHasKey( 'Null', $row );
+ $this->assertIsString( $row['Null'] );
+ $this->assertSame( 'YES', $row['Null'], "Expected cycles.{$column} to be NULLable." );
+ }
+ }
+
/**
* @testdox A chain is (contract_id, kind), so cycles carry no chain_id column.
*/
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 b4a8ac085a2..87ba8a67499 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
@@ -22,6 +22,7 @@ class CycleStatusTest extends TestCase {
$this->assertSame(
array(
CycleStatus::PENDING,
+ CycleStatus::PROCESSING,
CycleStatus::BILLED,
CycleStatus::FAILED,
CycleStatus::CANCELLED,
@@ -32,22 +33,33 @@ class CycleStatusTest extends TestCase {
public function test_known_statuses_are_valid(): void {
$this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
+ $this->assertTrue( CycleStatus::is_valid( CycleStatus::PROCESSING ) );
$this->assertTrue( CycleStatus::is_valid( CycleStatus::BILLED ) );
$this->assertTrue( CycleStatus::is_valid( CycleStatus::FAILED ) );
$this->assertTrue( CycleStatus::is_valid( CycleStatus::CANCELLED ) );
$this->assertFalse( CycleStatus::is_valid( 'nonsense' ) );
}
- public function test_pending_reaches_billed_failed_and_cancelled(): void {
+ public function test_pending_reaches_processing_billed_failed_and_cancelled(): void {
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PROCESSING ) );
$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ) );
$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::FAILED ) );
$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::CANCELLED ) );
}
- public function test_failed_can_only_be_cancelled(): void {
- // Retry support (failed -> pending re-queue) is deferred; for now a failed cycle can only be cancelled.
+ public function test_processing_settles_to_billed_failed_or_cancelled_but_not_back_to_pending(): void {
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::BILLED ) );
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::FAILED ) );
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::CANCELLED ) );
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PROCESSING, CycleStatus::PENDING ) );
+ $this->assertFalse( CycleStatus::is_terminal( CycleStatus::PROCESSING ) );
+ }
+
+ public function test_failed_can_be_retried_to_pending_or_cancelled(): void {
+ // A failed cycle can be retried (re-queued to pending by an admin trigger) or cancelled,
+ // but not settled directly to billed without re-attempting the charge.
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::PENDING ) );
$this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::CANCELLED ) );
- $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::PENDING ) );
$this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED ) );
}
@@ -61,8 +73,9 @@ class CycleStatusTest extends TestCase {
}
}
- public function test_pending_and_failed_are_not_terminal(): void {
+ 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 ) );
}
@@ -107,6 +120,7 @@ class CycleStatusTest extends TestCase {
public function test_named_factories_carry_their_status_value(): void {
$this->assertSame( CycleStatus::PENDING, CycleStatus::pending()->get_value() );
+ $this->assertSame( CycleStatus::PROCESSING, CycleStatus::processing()->get_value() );
$this->assertSame( CycleStatus::BILLED, CycleStatus::billed()->get_value() );
$this->assertSame( CycleStatus::FAILED, CycleStatus::failed()->get_value() );
$this->assertSame( CycleStatus::CANCELLED, CycleStatus::cancelled()->get_value() );
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
new file mode 100644
index 00000000000..ad8cd4d2b9e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Ownership/ConsumerRegistryTest.php
@@ -0,0 +1,99 @@
+<?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
new file mode 100644
index 00000000000..f770783e326
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Renewal/RenewalSelectorTest.php
@@ -0,0 +1,117 @@
+<?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' ) ) );
+ }
+}