Commit e9a96750489 for woocommerce
commit e9a967504899c1515a6d02342129f0e17c11bbb4
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Thu Jun 25 19:06:43 2026 +0200
Add renewal advancement and public API to the subscriptions engine (#66003)
- Advance the billing chain on a due renewal: claim the next cycle as a pending create-as-claim (idempotent via the UNIQUE index), build and charge a renewal order from the contract's own addresses and recurring items, then settle it billed or failed and move the schedule
- Compute the next cycle in a pure, WordPress-free RenewalCalculator from the plan's billing policy
- Add the public Api\Subscriptions facade (get, list, get_history, cancel, renew_now) and a WooCommerce-style ContractRepository::query for list screens
- Extract cancellation into a focused Integration\Contracts\Cancellation that also closes a mid-charge cycle, and fire billed/cancelled lifecycle actions
- Cover the money-path with unit and integration tests (advancement, failed charge, idempotency, cancellation, and graceful skips)
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-advancement b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-advancement
new file mode 100644
index 00000000000..adb3e2943e4
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-advancement
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add subscriptions engine renewal advancement money-path and Api\Subscriptions facade.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
new file mode 100644
index 00000000000..1775d2b05c0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Subscriptions - the engine's public consumer facade.
+ *
+ * The one surface consumers (a host plugin's admin UI, tests) import to read and act
+ * on subscriptions: read the contract and its cycle history, cancel, and run a renewal
+ * now. It hides the internal `Core\` / `Integration\` collaborators (the repository,
+ * the renewal engine) behind a stable boundary, so the internals stay refactorable.
+ *
+ * Interim return types: it returns the core entities ({@see Contract}, {@see Cycle})
+ * and `WC_Order` directly for now; richer read-model views are a planned follow-up, so
+ * consumers also reference those types until the views land. `Api\` is the public
+ * surface, not a third internal zone - the two-zone (Core/Integration) model still
+ * describes the internals.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public subscriptions facade.
+ *
+ * Final and static-only: a stateless entry point, not an extension seam.
+ */
+final class Subscriptions {
+
+ /**
+ * Fetch a subscription contract by id.
+ *
+ * @param int $contract_id Contract id.
+ * @return Contract|null The contract, or null when none exists.
+ */
+ public static function get( int $contract_id ): ?Contract {
+ return ( new ContractRepository() )->find( $contract_id );
+ }
+
+ /**
+ * List the most-recent subscription contracts, newest first.
+ *
+ * @param int $limit Maximum contracts to return.
+ * @param int $offset Contracts to skip (for paging).
+ * @return array<int, Contract> Contracts newest first.
+ */
+ public static function list( int $limit = 20, int $offset = 0 ): array {
+ return ( new ContractRepository() )->query(
+ array(
+ 'limit' => $limit,
+ 'offset' => $offset,
+ )
+ );
+ }
+
+ /**
+ * Fetch a window of the contract's billing cycle history, newest first.
+ *
+ * @param int $contract_id Contract id.
+ * @param int $limit Maximum cycles to return.
+ * @return array<int, Cycle> Cycles newest first.
+ */
+ public static function get_history( int $contract_id, int $limit = 20 ): array {
+ return ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit );
+ }
+
+ /**
+ * Cancel a subscription contract.
+ *
+ * @param int $contract_id Contract id.
+ * @return bool True when the contract was found and cancelled; false when not found.
+ */
+ public static function cancel( int $contract_id ): bool {
+ $contract = ( new ContractRepository() )->find( $contract_id );
+ if ( null === $contract ) {
+ return false;
+ }
+
+ return ( new Cancellation() )->cancel( $contract );
+ }
+
+ /**
+ * Run the contract's renewal now (advance the billing chain a cycle).
+ *
+ * @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 );
+ }
+}
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 27e6782449a..5f57b331599 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -413,6 +413,16 @@ final class Cycle {
return $this->order_id;
}
+ /**
+ * Link the cycle to its order. One of the few post-freeze-mutable fields (with
+ * `status` and `reason`): the order id is stamped once the charge order exists.
+ *
+ * @param int $order_id Linked order id.
+ */
+ public function set_order_id( int $order_id ): void {
+ $this->order_id = $order_id;
+ }
+
/**
* Owning extension slug, or null.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
index 3ea8df4a4c5..2c66e7ca55b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
@@ -1,18 +1,18 @@
<?php
/**
- * RenewalCalculator - pure renewal date math and the cycle-advance decision.
+ * RenewalCalculator - pure renewal date math and the next-cycle builder.
*
- * Answers two questions the renewal money-path needs, with no knowledge of
+ * Answers the questions the renewal money-path needs with no knowledge of
* WordPress, Action Scheduler, orders, or the wall clock: given a plan's
- * {@see BillingPolicy} and where a contract is in its cycle count,
+ * {@see BillingPolicy} and where a contract is in its chain,
*
- * 1. has the contract reached its hard end (max_cycles)? and
- * 2. if not, when is the next bill date, computed from an explicit `$now`?
+ * 1. has the contract reached its hard end (max_cycles)?
+ * 2. when is the next bill date, computed from an explicit anchor?
+ * 3. what is the next {@see Cycle} the contract advances into?
*
- * Core zone: WordPress-free by design. No WP/Woo symbols, and no time
- * functions - `$now` is always passed in by the integration layer so the math
- * is deterministic and unit-testable. All date math delegates to
- * {@see BillingPolicy} so there is a single cadence-math path in the package.
+ * `$now`/anchors are always passed in by the integration layer so the math is
+ * deterministic and unit-testable. All cadence math delegates to
+ * {@see BillingPolicy} so there is a single cadence path in the package.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal
*/
@@ -22,6 +22,9 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal;
use DateTimeImmutable;
+use DateTimeZone;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
defined( 'ABSPATH' ) || exit;
@@ -31,8 +34,8 @@ defined( 'ABSPATH' ) || exit;
*
* Stateless: every method is static and derives its answer solely from its
* arguments. The integration layer (`Integration\Renewal\RenewalEngine`) reads
- * the contract row, calls into here for the decision, then writes the result
- * back and talks to Action Scheduler / the gateway.
+ * the contract row, resolves its billing policy, calls in here for the next
+ * cycle, then persists it and talks to Action Scheduler / the gateway.
*/
final class RenewalCalculator {
@@ -64,14 +67,11 @@ final class RenewalCalculator {
}
/**
- * Compute the next bill date for a contract advancing past `$current_period_start`.
+ * Compute the next bill date one cadence past `$current_period_start`.
*
- * Anchors on the moment the just-completed cycle was due (or, for the
- * payment-anchored chain, the moment payment landed) and adds one cadence.
* Delegates to {@see BillingPolicy::compute_next_renewal_from()} so calendar
* semantics (month-end roll-over, DST) stay in one place. The result is in
- * UTC; the caller formats it to the `Y-m-d H:i:s` GMT string the contract
- * row stores.
+ * UTC; the caller formats it to the `Y-m-d H:i:s` GMT string the rows store.
*
* @param BillingPolicy $policy The plan's billing policy.
* @param DateTimeImmutable $current_period_start The anchor the next cycle is measured from.
@@ -80,4 +80,46 @@ final class RenewalCalculator {
public static function next_bill_date( BillingPolicy $policy, DateTimeImmutable $current_period_start ): DateTimeImmutable {
return $policy->compute_next_renewal_from( $current_period_start );
}
+
+ /**
+ * Build the next cycle a contract advances into, one cadence forward from the
+ * anchor the caller supplies.
+ *
+ * The period runs from `period_start` one cadence forward (calendar-aware, via
+ * the billing policy) - a real one-cadence period, never zero-duration. The
+ * amount, currency, count, and snapshot references are the contract's live
+ * values, passed in by the caller, so the cycle reflects the contract rather
+ * than a possibly-stale earlier cycle. The returned cycle is `pending`, ready
+ * for the integration layer to claim (insert) and then charge.
+ *
+ * @param BillingPolicy $policy The billing policy whose cadence the period runs on.
+ * @param array<string, mixed> $values Cycle inputs: `contract_id`, `sequence_no`, `count`,
+ * `period_start` (GMT `Y-m-d H:i:s` anchor), `expected_total`,
+ * `currency`, `extension_slug`, `plan_snapshot_id`,
+ * `items_snapshot_id`.
+ * @return Cycle The pending next cycle.
+ */
+ public static function compute_next_cycle( BillingPolicy $policy, array $values ): Cycle {
+ $anchor = $values['period_start'] ?? null;
+ $start = new DateTimeImmutable( is_string( $anchor ) ? $anchor : '', new DateTimeZone( 'UTC' ) );
+ $end = $policy->compute_next_renewal_from( $start );
+
+ // Cycle::create() coerces and validates each attribute, so the contract's values pass
+ // through as given; only the period boundaries are computed here.
+ return Cycle::create(
+ array(
+ 'contract_id' => $values['contract_id'] ?? null,
+ 'sequence_no' => $values['sequence_no'] ?? null,
+ 'count' => $values['count'] ?? null,
+ 'status' => CycleStatus::pending(),
+ 'starts_at_gmt' => $start->format( 'Y-m-d H:i:s' ),
+ 'ends_at_gmt' => $end->format( 'Y-m-d H:i:s' ),
+ 'expected_total' => $values['expected_total'] ?? null,
+ 'currency' => $values['currency'] ?? null,
+ 'extension_slug' => $values['extension_slug'] ?? null,
+ 'plan_snapshot_id' => $values['plan_snapshot_id'] ?? null,
+ 'items_snapshot_id' => $values['items_snapshot_id'] ?? null,
+ )
+ );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
new file mode 100644
index 00000000000..de28dffc92d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Cancellation - cancel a subscription contract.
+ *
+ * A focused contract-management operation (deliberately not a catch-all manager):
+ * transition the contract to cancelled, close any charge caught mid-flight, clear its
+ * pending renewal, and announce it. Lives under `Integration\Contracts` so contract
+ * lifecycle stays separate from the renewal money-path.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts;
+
+use RuntimeException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Cancel a contract.
+ */
+final class Cancellation {
+
+ /**
+ * Action fired after a contract is cancelled, with `( $contract )`.
+ */
+ const CONTRACT_CANCELLED_ACTION = 'woocommerce_subscriptions_engine_contract_cancelled';
+
+ /**
+ * Contract repository.
+ *
+ * @var ContractRepository
+ */
+ private $contracts;
+
+ /**
+ * Construct.
+ *
+ * @param ContractRepository|null $contracts Contract repository; default instance when omitted.
+ */
+ public function __construct( ?ContractRepository $contracts = null ) {
+ $this->contracts = $contracts ?? new ContractRepository();
+ }
+
+ /**
+ * Cancel `$contract`: transition to cancelled, close any mid-charge cycle, and clear its
+ * pending renewal.
+ *
+ * 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.
+ *
+ * @param Contract $contract Contract to cancel. Must have an id.
+ * @return bool True when the contract was cancelled and persisted.
+ * @throws RuntimeException If the contract has no id.
+ */
+ public function cancel( Contract $contract ): bool {
+ $id = $contract->get_id();
+ if ( null === $id ) {
+ throw new RuntimeException( 'Cancellation::cancel(): cannot cancel a contract that has no id.' );
+ }
+
+ $contract->set_status( ContractStatus::CANCELLED );
+ $this->contracts->update( $contract );
+
+ // 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 );
+ 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.
+ *
+ * @param Contract $contract The cancelled contract.
+ */
+ do_action( self::CONTRACT_CANCELLED_ACTION, $contract );
+
+ return true;
+ }
+}
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 acaf96ec851..4652042de0c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -1,14 +1,15 @@
<?php
/**
* The seam between consumers and the renewal money-path: `schedule()` enqueues a
- * contract's next renewal, `process_due()` runs it when Action Scheduler fires.
- * 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}.
+ * 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}.
*
- * Advancing the chain at fire time (appending the next cycle, recording the outcome,
- * advancing `next_payment_gmt`, re-arming the next due moment) is the dispatcher
- * slice's money-path and is not built here, so this unit does not drive a live loop.
+ * `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.
*
* Integration zone: WordPress-native. Action Scheduler, WC orders, gateways.
*
@@ -23,12 +24,20 @@ use DateTimeImmutable;
use DateTimeZone;
use Throwable;
use WC_Order;
+use WC_Order_Item_Product;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\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\PlanRepository;
defined( 'ABSPATH' ) || exit;
@@ -48,6 +57,12 @@ final class RenewalEngine {
*/
const RENEWAL_ORDER_CREATED_ACTION = 'woocommerce_subscriptions_engine_renewal_order_created';
+ /**
+ * Action fired after a renewal cycle is billed and the schedule advanced, with
+ * `( $contract, $cycle, $renewal_order )`.
+ */
+ const RENEWAL_BILLED_ACTION = 'woocommerce_subscriptions_engine_renewal_billed';
+
/**
* Logger source tag.
*/
@@ -61,12 +76,21 @@ final class RenewalEngine {
private $contracts;
/**
- * Build a renewal engine over the given contract repository.
+ * Repository for loading the contract's selling plan (the cadence source).
+ *
+ * @var PlanRepository
+ */
+ private $plans;
+
+ /**
+ * Build a renewal engine over the given repositories.
*
* @param ContractRepository|null $contracts Contract repository; default instance when omitted.
+ * @param PlanRepository|null $plans Plan repository; default instance when omitted.
*/
- public function __construct( ?ContractRepository $contracts = null ) {
+ public function __construct( ?ContractRepository $contracts = null, ?PlanRepository $plans = null ) {
$this->contracts = $contracts ?? new ContractRepository();
+ $this->plans = $plans ?? new PlanRepository();
}
/**
@@ -158,13 +182,19 @@ final class RenewalEngine {
*
* 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 guards idempotency - if a renewal order for the next chargeable number in
- * the billing chain already exists, no second one is created (tolerating AS retries) -
- * and builds the renewal order and attempts the gateway charge. Advancing the chain
- * is the dispatcher slice's money-path, so this does not drive a live renewal loop.
+ * 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.
+ * @return WC_Order|null The created renewal order, or null when skipped/idempotent.
*/
public function process_due( int $contract_id ): ?WC_Order {
$contract = $this->contracts->find( $contract_id );
@@ -202,10 +232,25 @@ final class RenewalEngine {
return null;
}
- $next_count = $this->next_chargeable_count( $contract_id );
+ $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,
+ )
+ );
+ return null;
+ }
+
+ $next_count = $this->target_count( $previous );
- // Idempotency: a renewal order already tagged for this number means the action
- // already ran (AS retry, double-fire). Bail without creating a second order.
+ // 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 ),
@@ -217,9 +262,48 @@ final class RenewalEngine {
return null;
}
- $renewal_order = $this->build_renewal_order( $contract, $next_count );
+ // 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,
+ )
+ );
+ 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 ) ) {
+ return null;
+ }
+
+ $renewal_order = $this->build_renewal_order( $contract, $next_count, $new_cycle->get_expected_total() );
if ( null === $renewal_order ) {
- // build_renewal_order logged the reason.
+ // 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;
}
@@ -227,90 +311,180 @@ final class RenewalEngine {
$this->attempt_charge( $renewal_order, $contract );
- // Advancing the chain and re-arming the next due moment is the dispatcher
- // slice's money-path - deferred, so the loop is not driven from here.
+ $this->resolve_outcome( $contract, $new_cycle, $renewal_order );
return $renewal_order;
}
/**
- * Cancel `$contract`: transition to cancelled and clear its pending renewal.
+ * The chargeable number this renewal targets - the idempotency anchor.
*
- * Status moves through the Core state machine ({@see Contract::set_status()}),
- * which raises a `DomainException` on an illegal transition.
+ * 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.
*
- * @param Contract $contract Contract to cancel. Must have an id.
- * @return bool True when the contract was cancelled and persisted.
- * @throws \RuntimeException If the contract has no id.
+ * 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.
*/
- public function cancel( Contract $contract ): bool {
- $id = $contract->get_id();
- if ( null === $id ) {
- throw new \RuntimeException( 'RenewalEngine::cancel(): cannot cancel a contract that has no id.' );
+ 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()
+ )
+ );
}
- $contract->set_status( ContractStatus::CANCELLED );
- // update() persists the contract-row cache/status ONLY. Closing the scheduled
- // cycle and any chain/cycle head transition is the dispatcher money-path.
- $this->contracts->update( $contract );
+ $status = $previous->get_status()->get_value();
+ $settled_forward = CycleStatus::BILLED === $status || CycleStatus::CANCELLED === $status;
- RenewalScheduler::unschedule( $id );
-
- return true;
+ return $settled_forward ? (int) $count + 1 : (int) $count;
}
/**
- * The next chargeable number in the contract's billing chain - one past the
- * per-chain `MAX(count)`, or 1 for a chain with no counting cycle yet. This is
- * the idempotency anchor the renewal order is tagged with.
+ * Resolve the billing policy the next cycle bills under, from the contract's own plan
+ * snapshot - the live source of truth, so a contract updated since an earlier cycle bills
+ * on its current terms. Falls back to the contract's selling plan when it carries no
+ * snapshot, and returns null when neither resolves (a deleted plan) so the caller skips
+ * gracefully rather than mis-billing.
*
- * @param int $contract_id Contract id.
- * @return int The next chargeable number.
+ * @param Contract $contract The contract being renewed.
+ * @return BillingPolicy|null The billing policy, or null when unresolvable.
*/
- private function next_chargeable_count( int $contract_id ): int {
- $max = $this->contracts->max_count( $contract_id );
+ private function resolve_billing_policy( Contract $contract ): ?BillingPolicy {
+ $snapshot = $this->contracts->find_plan_snapshot( $contract->get_plan_snapshot_id() );
+ if ( $snapshot instanceof PlanSnapshot ) {
+ $payload = $snapshot->to_array();
+ if ( isset( $payload['billing_policy'] ) && is_array( $payload['billing_policy'] ) ) {
+ try {
+ return BillingPolicy::from_array( self::string_keyed( $payload['billing_policy'] ) );
+ } catch ( \DomainException $e ) {
+ // A corrupt stored policy must not crash the scheduled run; fall through to the
+ // live plan below so the renewal can still resolve on current terms.
+ wc_get_logger()->warning(
+ sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot billing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ )
+ );
+ }
+ }
+ }
- return null === $max ? 1 : $max + 1;
+ $plan = $this->plans->find( $contract->get_selling_plan_id() );
+ return $plan instanceof Plan ? $plan->get_billing_policy() : null;
}
/**
- * Build a renewal order cloned from the contract's origin order: clones
- * line/fee/shipping/tax/coupon items and addresses, applies the current 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 the origin
- * order cannot be loaded or `wc_create_order()` fails.
+ * 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.
*
- * @param Contract $contract Contract being renewed.
- * @param int $count The chargeable number this order bills.
- * @return WC_Order|null The saved pending renewal order, or null on failure.
+ * @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.
*/
- private function build_renewal_order( Contract $contract, int $count ): ?WC_Order {
- $origin_order_id = $contract->get_origin_order_id();
- if ( null === $origin_order_id ) {
- // A manual/admin contract has no origin order to clone from - not supported yet.
- wc_get_logger()->error(
- sprintf( 'RenewalEngine: cannot build renewal for contract %d - it has no origin order to clone.', (int) $contract->get_id() ),
+ private function claim_cycle( Cycle $cycle, ?Cycle $previous ): bool {
+ try {
+ $this->contracts->append_cycle( $cycle, $previous );
+ } 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.
+ 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() ),
array(
'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
+ 'contract_id' => $cycle->get_contract_id(),
)
);
- return null;
+ return false;
}
- $origin = wc_get_order( $origin_order_id );
- if ( ! $origin instanceof WC_Order ) {
- wc_get_logger()->error(
- sprintf( 'RenewalEngine: cannot build renewal for contract %d - origin order %d not found.', (int) $contract->get_id(), $origin_order_id ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => (int) $contract->get_id(),
- )
- );
- return null;
+ return true;
+ }
+
+ /**
+ * Resolve the renewal outcome from the order's paid state.
+ *
+ * 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.
+ *
+ * @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.
+ */
+ private function resolve_outcome( Contract $contract, Cycle $cycle, WC_Order $renewal_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();
+
+ // 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() );
+
+ if ( $paid ) {
+ // CAS pending -> billed (the entity validates the transition).
+ $cycle->set_status( CycleStatus::billed() );
+ $this->contracts->update_cycle( $cycle );
+
+ $contract->set_next_payment_gmt( $cycle->get_ends_at_gmt() );
+ $contract->set_last_payment_gmt( $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.
+ */
+ do_action( self::RENEWAL_BILLED_ACTION, $contract, $cycle, $renewal_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 );
+
+ $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
+ * 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.
+ *
+ * @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).
+ * @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 {
$renewal_order = wc_create_order(
array(
'customer_id' => $contract->get_customer_id(),
@@ -340,21 +514,56 @@ final class RenewalEngine {
$renewal_order->set_payment_method_title( (string) $instrument->get_title() );
}
- $renewal_order->set_address( $origin->get_address( 'billing' ), 'billing' );
- $renewal_order->set_address( $origin->get_address( 'shipping' ), 'shipping' );
+ // Addresses come from the contract (its live source of truth), not the origin order. The
+ // array setters only hydrate the order in memory (persisted by the save() below), unlike
+ // the legacy set_address() which writes post meta directly.
+ $addresses = $contract->get_addresses();
+ if ( isset( $addresses['billing'] ) && is_array( $addresses['billing'] ) ) {
+ $renewal_order->set_billing_address( $addresses['billing'] );
+ }
+ if ( isset( $addresses['shipping'] ) && is_array( $addresses['shipping'] ) ) {
+ $renewal_order->set_shipping_address( $addresses['shipping'] );
+ }
- // `set_id( 0 )` turns each clone into a fresh row on the renewal order rather
- // than UPDATE-ing the origin's row.
- foreach ( $origin->get_items( array( 'line_item', 'fee', 'shipping', 'tax', 'coupon' ) ) as $item ) {
- $clone = clone $item;
- $clone->set_id( 0 );
- $renewal_order->add_item( $clone );
+ // Only the contract's recurring line items - the origin order's one-time cart items are
+ // deliberately excluded so a mixed checkout cannot leak onto a renewal. A line for a
+ // since-deleted product makes WC_Order_Item_Product::set_product_id() throw; treat the
+ // whole build as a recoverable skip (logged, null) rather than let it reach the scheduler
+ // as a permanent failure that retries forever.
+ try {
+ foreach ( $contract->get_items() as $item ) {
+ $line = new WC_Order_Item_Product();
+ $line->set_name( self::item_string( $item, 'item_name' ) );
+ $line->set_product_id( self::item_int( $item, 'product_id' ) );
+ $line->set_variation_id( self::item_int( $item, 'variation_id' ) );
+ $line->set_quantity( max( 1, self::item_int( $item, 'quantity' ) ) );
+ $line->set_subtotal( self::item_string( $item, 'subtotal' ) );
+ $line->set_total( self::item_string( $item, 'total' ) );
+ $renewal_order->add_item( $line );
+ }
+ } catch ( Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'RenewalEngine: cannot build renewal items for contract %d (a product may have been deleted): %s', (int) $contract->get_id(), $e->getMessage() ),
+ array(
+ 'source' => self::LOG_SOURCE,
+ 'contract_id' => (int) $contract->get_id(),
+ )
+ );
+ return null;
}
- // The current cycle's expected_total is the price authority - applied after
- // add_item() so the line items do not recompute over it. Reconstructing the
- // granular discount/shipping/tax breakdown is the dispatcher money-path's job.
- $renewal_order->set_total( $this->current_cycle_total( (int) $contract->get_id() ) );
+ // The new cycle's expected_total is the price authority - applied after add_item() so
+ // the line items do not recompute over it. Reconstructing the granular discount /
+ // shipping / tax breakdown is a later money-path's job.
+ $renewal_order->set_total( $expected_total );
+
+ // Tag the renewal relation + chargeable number so 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).
+ $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 ) {
@@ -364,29 +573,43 @@ final class RenewalEngine {
}
}
- // Tag the renewal relation + chargeable number so the idempotency check can
- // detect a duplicate fire for the same number.
- $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();
-
return $renewal_order;
}
/**
- * The amount the contract's current cycle expects to bill (its `expected_total`).
- * A contract with no cycle yet yields '0', making the charge a no-op rather than a
- * fatal - the safe state for a contract with nothing to bill.
+ * Read a contract-item field as a string, defaulting to empty when absent or non-scalar.
+ *
+ * @param array<string, mixed> $item The contract item row.
+ * @param string $key Field key.
+ */
+ private static function item_string( array $item, string $key ): string {
+ $value = $item[ $key ] ?? null;
+ return is_scalar( $value ) ? (string) $value : '';
+ }
+
+ /**
+ * Read a contract-item field as an int, defaulting to 0 when absent or non-numeric.
*
- * @param int $contract_id The contract being renewed.
- * @return string Decimal-safe amount string.
+ * @param array<string, mixed> $item The contract item row.
+ * @param string $key Field key.
*/
- private function current_cycle_total( int $contract_id ): string {
- $cycle = $this->contracts->find_current_cycle( $contract_id );
+ private static function item_int( array $item, string $key ): int {
+ $value = $item[ $key ] ?? null;
+ return is_numeric( $value ) ? (int) $value : 0;
+ }
- return null === $cycle ? '0' : $cycle->get_expected_total();
+ /**
+ * Coerce a decoded array to a string-keyed array for the typed value-object factories.
+ *
+ * @param array<mixed, mixed> $value The decoded array.
+ * @return array<string, mixed>
+ */
+ private static function string_keyed( array $value ): array {
+ $out = array();
+ foreach ( $value as $key => $item ) {
+ $out[ (string) $key ] = $item;
+ }
+ return $out;
}
/**
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 1adb1460296..7f0745612a3 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -213,6 +213,43 @@ final class ContractRepository {
return Contract::from_storage( $row );
}
+ /**
+ * Query a window of contracts for list screens, newest first (id DESC). Hydrated
+ * lightweight (row only, no children) like {@see self::find_summary()}.
+ *
+ * Takes a WooCommerce-style args array (cf. `wc_get_orders()`) rather than positional
+ * paging args, so the shape can widen to status / search / sort without a signature
+ * change. Only the paging args are honoured for now.
+ *
+ * @param array<string, mixed> $args {
+ * Optional. Query args.
+ *
+ * @type int $limit Maximum contracts to return. Default 20.
+ * @type int $offset Rows to skip (for paging). Default 0.
+ * }
+ * @return array<int, Contract> Contracts newest first.
+ */
+ public function query( array $args = array() ): array {
+ global $wpdb;
+
+ $limit = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? (int) $args['limit'] : 20;
+ $offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? (int) $args['offset'] : 0;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A );
+
+ $contracts = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ if ( is_array( $row ) ) {
+ $contracts[] = Contract::from_storage( self::as_string_keyed( $row ) );
+ }
+ }
+
+ return $contracts;
+ }
+
/**
* Whether a contract row exists for `$id`.
*
@@ -537,10 +574,13 @@ final class ContractRepository {
/**
* Decode a stored plan snapshot row into a typed value object.
*
+ * Public so the renewal money-path can resolve a contract's own frozen plan terms
+ * (the cadence to bill the next cycle under) from its `plan_snapshot_id`.
+ *
* @param int|null $snapshot_id Snapshot row id, or null.
* @return PlanSnapshot|null The decoded value object, or null.
*/
- private function find_plan_snapshot( ?int $snapshot_id ): ?PlanSnapshot {
+ public function find_plan_snapshot( ?int $snapshot_id ): ?PlanSnapshot {
$decoded = $this->find_snapshot_payload( $snapshot_id );
if ( null === $decoded ) {
return null;
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
new file mode 100644
index 00000000000..1c612da3db7
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * Integration tests for the public Subscriptions facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
+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\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions
+ */
+class SubscriptionsTest extends EngineIntegrationTestCase {
+
+ /**
+ * Gateway id used for the lifecycle charge - declares `recurring` and completes
+ * the charge inline (the dummy-gateway shape), matching the real gateway used in CI.
+ */
+ private const GATEWAY = 'dummy';
+
+ public function set_up(): void {
+ parent::set_up();
+ GatewayCapabilities::reset();
+ $this->approve_charges_for( self::GATEWAY );
+ }
+
+ public function tear_down(): void {
+ GatewayCapabilities::reset();
+ parent::tear_down();
+ }
+
+ /**
+ * Sign up a contract via the checkout factory (cycle 1 billed).
+ *
+ * @return Contract The persisted contract with cycle 1 billed.
+ */
+ private function sign_up_contract(): Contract {
+ $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 );
+
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( self::GATEWAY );
+ $order->set_total( '19.99' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ $order->save();
+
+ return ( new ContractFactory() )->create_from_order( $order, $plan );
+ }
+
+ /**
+ * @testdox get returns the contract, and null for an unknown id.
+ */
+ public function test_get_round_trips_a_contract(): void {
+ $contract = $this->sign_up_contract();
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $loaded = Subscriptions::get( $contract_id );
+ $this->assertInstanceOf( Contract::class, $loaded );
+ $this->assertSame( $contract_id, $loaded->get_id() );
+
+ $this->assertNull( Subscriptions::get( 999999 ) );
+ }
+
+ /**
+ * @testdox list returns recent contracts newest first.
+ */
+ public function test_list_returns_recent_contracts(): void {
+ $first = $this->sign_up_contract();
+ $second = $this->sign_up_contract();
+
+ $contracts = Subscriptions::list();
+ $ids = array_map( static fn ( Contract $c ) => $c->get_id(), $contracts );
+
+ // Newest first, and both signups are present.
+ $this->assertSame( array( $second->get_id(), $first->get_id() ), array_slice( $ids, 0, 2 ) );
+ $this->assertInstanceOf( Contract::class, $contracts[0] );
+ }
+
+ /**
+ * @testdox get_history returns the billing cycles newest first.
+ */
+ public function test_get_history_returns_cycles(): void {
+ $contract = $this->sign_up_contract();
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $history = Subscriptions::get_history( $contract_id );
+ $this->assertCount( 1, $history );
+ $this->assertInstanceOf( Cycle::class, $history[0] );
+ $this->assertSame( 1, $history[0]->get_count() );
+ }
+
+ /**
+ * @testdox cancel returns false for an unknown contract.
+ */
+ public function test_cancel_unknown_contract_returns_false(): void {
+ $this->assertFalse( Subscriptions::cancel( 999999 ) );
+ }
+
+ /**
+ * @testdox renew_now returns null for an unknown contract.
+ */
+ public function test_renew_now_unknown_contract_returns_null(): void {
+ $this->assertNull( Subscriptions::renew_now( 999999 ) );
+ }
+
+ /**
+ * @testdox The full lifecycle runs through the facade: buy, renew, cancel.
+ */
+ public function test_full_lifecycle_buy_renew_cancel(): void {
+ // Buy: signup builds cycle 1 (billed).
+ $contract = $this->sign_up_contract();
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+ // Monthly plan, paid 2026-01-15: first renewal is one month out.
+ $this->assertSame( '2026-02-15 00:00:00', $contract->get_next_payment_gmt() );
+
+ // Renew: advance the chain a cycle through the facade.
+ $renewal_order = Subscriptions::renew_now( $contract_id );
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+ $this->assertTrue( $renewal_order->is_paid() );
+
+ $history = Subscriptions::get_history( $contract_id );
+ $this->assertCount( 2, $history );
+
+ // Newest first: cycle 2 is billed, linked to the renewal order.
+ $cycle_two = $history[0];
+ $this->assertSame( 2, $cycle_two->get_count() );
+ $this->assertTrue( $cycle_two->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertSame( $renewal_order->get_id(), $cycle_two->get_order_id() );
+
+ // The schedule advanced one cadence (cycle 1 ended 2026-02-15 + 1 month).
+ $after_renew = Subscriptions::get( $contract_id );
+ $this->assertInstanceOf( Contract::class, $after_renew );
+ $this->assertSame( '2026-03-15 00:00:00', $after_renew->get_next_payment_gmt() );
+
+ // Cancel: the contract goes terminal.
+ $this->assertTrue( Subscriptions::cancel( $contract_id ) );
+
+ $after_cancel = Subscriptions::get( $contract_id );
+ $this->assertInstanceOf( Contract::class, $after_cancel );
+ $this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index 08dc8cc73ae..de289b3331d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -10,8 +10,51 @@
declare( strict_types=1 );
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+
/**
* Engine integration test case.
*/
abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
+
+ /**
+ * Gateway ids wired with an approving scheduled-payment handler, to unhook on teardown.
+ *
+ * @var array<int, string>
+ */
+ private $approved_gateways = array();
+
+ public function tear_down(): void {
+ foreach ( $this->approved_gateways as $gateway ) {
+ remove_all_actions( 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway );
+ }
+ $this->approved_gateways = array();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Declare `recurring` for `$gateway` and wire an inline approving handler: it marks the
+ * renewal order paid synchronously (the dummy-gateway shape), so the money-path reads a
+ * paid order immediately after the charge is attempted. Unhooked automatically on teardown.
+ *
+ * @param string $gateway Gateway id to approve charges for.
+ */
+ protected function approve_charges_for( string $gateway ): void {
+ GatewayCapabilities::declare( $gateway, array( GatewayCapabilities::RECURRING ) );
+
+ add_action(
+ 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway,
+ static function ( $amount, $renewal_order ): void {
+ unset( $amount );
+ if ( $renewal_order instanceof WC_Order && $renewal_order->needs_payment() ) {
+ $renewal_order->payment_complete();
+ }
+ },
+ 10,
+ 2
+ );
+
+ $this->approved_gateways[] = $gateway;
+ }
}
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 622c64f0bde..49c882c98eb 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
@@ -13,11 +13,15 @@ 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\Contracts\Cancellation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -32,6 +36,18 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
private const GATEWAY = 'engine_test_gateway';
+ /**
+ * A gateway that always approves the scheduled charge, marking the renewal order
+ * paid inline (the dummy-gateway shape: `payment_complete()` within the action).
+ */
+ private const GATEWAY_APPROVING = 'engine_test_gateway_approve';
+
+ /**
+ * A gateway that declares `recurring` but never completes the charge, so the
+ * renewal order stays unpaid (the failed-charge path).
+ */
+ private const GATEWAY_DECLINING = 'engine_test_gateway_decline';
+
public function set_up(): void {
parent::set_up();
GatewayCapabilities::reset();
@@ -43,6 +59,15 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
private function make_plan( ?int $max_cycles = null ): int {
+ return (int) $this->make_plan_object( $max_cycles )->get_id();
+ }
+
+ /**
+ * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+ *
+ * @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
+ */
+ private function make_plan_object( ?int $max_cycles = null ): Plan {
$group_id = ( new PlanGroupRepository() )->insert( PlanGroup::create( array( 'name' => 'Club' ) ) );
$plan = Plan::create(
@@ -51,11 +76,33 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'name' => 'Monthly',
'billing_policy' => new BillingPolicy( 'month', 1, null, $max_cycles, null ),
'category' => Plan::DEFAULT_CATEGORY,
+ 'extension_slug' => 'engine-tests',
)
);
( new PlanRepository() )->insert( $plan );
- return (int) $plan->get_id();
+ return $plan;
+ }
+
+ /**
+ * Sign up a contract via the checkout factory so its billing chain holds cycle 1
+ * (billed), the starting point the renewal advances from.
+ *
+ * @param string $gateway Gateway id stamped on the order/contract.
+ * @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
+ * @return Contract The persisted contract with cycle 1 billed.
+ */
+ private function sign_up_contract( string $gateway, ?int $max_cycles = null ): Contract {
+ $plan = $this->make_plan_object( $max_cycles );
+
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( $gateway );
+ $order->set_total( '19.99' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ $order->save();
+
+ return ( new ContractFactory() )->create_from_order( $order, $plan );
}
private function make_origin_order(): WC_Order {
@@ -143,36 +190,34 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
* @testdox process_due creates a renewal order tagged for the next chargeable number.
*/
public function test_process_due_creates_renewal_order(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
$renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
- // Order creation is wired; advancing the chain is the dispatcher slice, so
- // only order creation + tagging is asserted here.
+ // The renewal order is created and tagged with the renewal relation + chargeable number.
$this->assertInstanceOf( WC_Order::class, $renewal_order );
$this->assertSame( (string) $contract_id, $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
$this->assertSame( OrderLinkage::RELATION_RENEWAL, $renewal_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
- // A lean contract has no counting cycle yet, so the next chargeable number is 1.
- $this->assertSame( '1', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
+ // The chain holds cycle 1 (from signup), so the renewal targets the next number, 2.
+ $this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
+ $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
}
/**
- * @testdox process_due is idempotent: a retried due action creates no second order.
+ * @testdox process_due skips when a renewal order is already tagged for the cycle.
+ *
+ * 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.
*/
- public function test_process_due_is_idempotent_for_a_retried_cycle(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+ public function test_process_due_skips_when_a_renewal_order_is_already_tagged(): void {
+ GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract = $this->sign_up_contract( self::GATEWAY_DECLINING );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
$engine = new RenewalEngine();
@@ -180,11 +225,324 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$first = $engine->process_due( $contract_id );
$this->assertInstanceOf( WC_Order::class, $first );
- // A retried due action for the same chargeable number is suppressed.
+ // 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 );
$this->assertNull( $retry );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
+ $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).
+ *
+ * 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.
+ */
+ public function test_process_due_skips_when_the_cycle_is_already_claimed(): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ // Pre-claim cycle 2 pending directly (no tagged renewal order), so the order-meta
+ // pre-check does not fire and the claim collides on the UNIQUE index instead.
+ $repo = new ContractRepository();
+ $previous = $repo->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $previous );
+ $claimed = Cycle::create(
+ array(
+ 'contract_id' => $contract_id,
+ 'sequence_no' => $previous->get_sequence_no() + 1,
+ 'count' => 2,
+ 'status' => CycleStatus::pending(),
+ 'starts_at_gmt' => '2026-02-15 00:00:00',
+ 'ends_at_gmt' => '2026-03-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ )
+ );
+ $repo->append_cycle( $claimed, $previous );
+
+ $result = ( new RenewalEngine() )->process_due( $contract_id );
+ $this->assertNull( $result );
+
+ // No renewal order was created for count 2, and only the one pre-claimed cycle exists.
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+ $at_count_2 = array_filter(
+ $repo->find_cycle_history( $contract_id ),
+ static function ( Cycle $cycle ): bool {
+ return 2 === $cycle->get_count();
+ }
+ );
+ $this->assertCount( 1, $at_count_2 );
+ }
+
+ /**
+ * @testdox process_due advances the chain: cycle 2 billed, order linked, schedule moved.
+ */
+ public function test_process_due_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 = ( new RenewalEngine() )->process_due( $contract_id );
+
+ // The renewal order bills the new cycle's expected_total (carried forward from
+ // cycle 1's recurring amount) and is paid by the approving gateway.
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+ $this->assertTrue( $renewal_order->is_paid() );
+ $this->assertSame( '2', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
+
+ $repo = new ContractRepository();
+ $cycle = $repo->find_current_cycle( $contract_id );
+
+ // Cycle 2 exists, billed, count 2, linked to the renewal order, refs carried forward.
+ $this->assertInstanceOf( Cycle::class, $cycle );
+ $this->assertSame( 2, $cycle->get_sequence_no() );
+ $this->assertSame( 2, $cycle->get_count() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );
+ $this->assertSame( '19.99000000', $cycle->get_expected_total() );
+ $this->assertSame( 'engine-tests', $cycle->get_extension_slug() );
+
+ // The contract schedule advanced one cadence; last_payment recorded.
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+ $this->assertNotNull( $reloaded->get_last_payment_gmt() );
+ $this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
+ }
+
+ /**
+ * @testdox process_due 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 {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'Monthly Filters' );
+ $product->set_regular_price( '19.99' );
+ $product_id = (int) $product->save();
+
+ // Sign up from an order carrying a real recurring line item + addresses, so the contract
+ // stores them and the renewal builder has something other than the origin order to read.
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( self::GATEWAY_APPROVING );
+ $order->set_total( '39.98' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ $order->set_billing_address(
+ array(
+ 'first_name' => 'Ada',
+ 'last_name' => 'Lovelace',
+ 'country' => 'US',
+ 'email' => 'ada@example.test',
+ )
+ );
+ $order->set_shipping_address(
+ array(
+ 'first_name' => 'Ada',
+ 'last_name' => 'Lovelace',
+ 'country' => 'US',
+ )
+ );
+ $line = new \WC_Order_Item_Product();
+ $line->set_name( 'Monthly Filters' );
+ $line->set_product_id( $product_id );
+ $line->set_quantity( 2 );
+ $line->set_subtotal( '39.98' );
+ $line->set_total( '39.98' );
+ $order->add_item( $line );
+ $order->save();
+
+ $contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan_object() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+
+ // Exactly the contract's recurring line item, carried from the contract.
+ $items = array_values( $renewal_order->get_items() );
+ $this->assertCount( 1, $items );
+ $item = $items[0];
+ $this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
+ $this->assertSame( $product_id, $item->get_product_id() );
+ $this->assertSame( 'Monthly Filters', $item->get_name() );
+ $this->assertSame( 2, $item->get_quantity() );
+
+ // Addresses are taken from the contract, not re-read off the origin order.
+ $this->assertSame( 'Ada', $renewal_order->get_billing_first_name() );
+ $this->assertSame( 'US', $renewal_order->get_billing_country() );
+ $this->assertSame( 'US', $renewal_order->get_shipping_country() );
+ }
+
+ /**
+ * @testdox process_due on a failed 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 ) );
+
+ $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 );
+
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+ $this->assertFalse( $renewal_order->is_paid() );
+
+ $repo = new ContractRepository();
+ $cycle = $repo->find_current_cycle( $contract_id );
+
+ // Cycle 2 exists and failed, but the renewal order is recorded on it even though the
+ // charge did not settle (for dunning + admin visibility).
+ $this->assertInstanceOf( Cycle::class, $cycle );
+ $this->assertSame( 2, $cycle->get_count() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::failed() ) );
+ $this->assertSame( $renewal_order->get_id(), $cycle->get_order_id() );
+
+ // The contract schedule is untouched (left for dunning), still active.
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
+ $this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
+
+ // Failure bookkeeping: the attempt is recorded, but not a successful payment.
+ $this->assertNull( $reloaded->get_last_payment_gmt() );
+ $this->assertNotNull( $reloaded->get_last_attempt_gmt() );
+ }
+
+ /**
+ * @testdox process_due retry of an unsettled 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.)
+ */
+ public function test_process_due_retry_of_an_unsettled_cycle_is_idempotent(): void {
+ GatewayCapabilities::declare( self::GATEWAY_DECLINING, array( GatewayCapabilities::RECURRING ) );
+
+ $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 );
+ $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 );
+ $this->assertNull( $second );
+
+ $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+
+ // Exactly one billing cycle for count 2.
+ $history = ( new ContractRepository() )->find_cycle_history( $contract_id );
+ $at_count_2 = array_filter(
+ $history,
+ static function ( Cycle $cycle ): bool {
+ return 2 === $cycle->get_count();
+ }
+ );
+ $this->assertCount( 1, $at_count_2 );
+ }
+
+ /**
+ * @testdox process_due 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
+ * 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 {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ // Precondition: the billed head cycle carries no in-memory plan snapshot, so the
+ // money-path must use the live-plan fallback to know the cadence.
+ $repo = new ContractRepository();
+ $head = $repo->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->assertNull( $head->get_plan_snapshot() );
+
+ $renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+
+ // Advanced one monthly cadence from the live plan (cycle 1 ended 2026-02-15).
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+
+ $cycle = $repo->find_current_cycle( $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.
+ *
+ * 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.
+ */
+ public function test_process_due_skips_a_contract_with_no_billing_chain(): void {
+ GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+ // A lean contract is persisted with no cycle chain.
+ $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 );
+ $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 ) );
+ }
+
+ /**
+ * @testdox process_due 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 {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ // 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 );
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+
+ // Cycle 2 was billed from the snapshot's cadence.
+ $cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $cycle );
+ $this->assertSame( 2, $cycle->get_count() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
}
/**
@@ -263,7 +621,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$engine->schedule( $contract );
$this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
- $this->assertTrue( $engine->cancel( $contract ) );
+ $this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
$reloaded = ( new ContractRepository() )->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
@@ -271,6 +629,60 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
+ /**
+ * @testdox cancel closes a mid-charge pending cycle.
+ */
+ public function test_cancel_closes_a_pending_cycle(): void {
+ $contract = $this->sign_up_contract( self::GATEWAY );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ // Append a pending cycle 2 (a charge caught mid-flight).
+ $repo = new ContractRepository();
+ $previous = $repo->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $previous );
+ $pending = Cycle::create(
+ array(
+ 'contract_id' => $contract_id,
+ 'sequence_no' => $previous->get_sequence_no() + 1,
+ 'count' => 2,
+ 'status' => CycleStatus::pending(),
+ 'starts_at_gmt' => '2026-02-15 00:00:00',
+ 'ends_at_gmt' => '2026-03-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ )
+ );
+ $repo->append_cycle( $pending, $previous );
+
+ $this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
+
+ // The contract is terminal and the pending cycle is cancelled.
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
+
+ $head = $repo->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->assertTrue( $head->get_status()->equals( CycleStatus::cancelled() ) );
+ }
+
+ /**
+ * @testdox cancel with only settled cycles leaves them untouched.
+ */
+ public function test_cancel_leaves_a_settled_cycle_untouched(): void {
+ $contract = $this->sign_up_contract( self::GATEWAY );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $this->assertTrue( ( new Cancellation() )->cancel( $contract ) );
+
+ // Cycle 1 stays billed (only a pending head is closed by cancel).
+ $head = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
+ }
+
/**
* @testdox A gateway-scheduled contract is not scheduled by the engine.
*/
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 b8d61c22776..f78303d8290 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
@@ -196,6 +196,34 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( array(), $summary->get_meta() );
}
+ /**
+ * @testdox query returns contracts newest first, lightweight, honouring limit/offset.
+ */
+ public function test_query_orders_newest_first_with_paging(): void {
+ $first = $this->sut->insert( $this->make_contract() );
+ $second = $this->sut->insert( $this->make_contract() );
+ $third = $this->sut->insert( $this->make_contract() );
+
+ // Newest first (id DESC), and hydrated lightweight (row only, no children).
+ $all = $this->sut->query();
+ $this->assertSame( array( $third, $second, $first ), array_map( static fn ( Contract $c ) => $c->get_id(), $all ) );
+ $this->assertInstanceOf( Contract::class, $all[0] );
+ $this->assertSame( array(), $all[0]->get_items() );
+
+ // Limit caps the window from the newest end.
+ $limited = $this->sut->query( array( 'limit' => 2 ) );
+ $this->assertSame( array( $third, $second ), array_map( static fn ( Contract $c ) => $c->get_id(), $limited ) );
+
+ // Offset skips from the newest end.
+ $offset = $this->sut->query(
+ array(
+ 'limit' => 2,
+ 'offset' => 1,
+ )
+ );
+ $this->assertSame( array( $second, $first ), array_map( static fn ( Contract $c ) => $c->get_id(), $offset ) );
+ }
+
/**
* @testdox A manual/admin contract with a null origin order round-trips.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
index 40c14f6ee96..46fb9fe8dbe 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
@@ -12,6 +12,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Renewal;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
@@ -24,6 +25,29 @@ class RenewalCalculatorTest extends TestCase {
return new BillingPolicy( $period, $interval, null, $max_cycles, null );
}
+ /**
+ * The standard next-cycle inputs, overridable per test.
+ *
+ * @param array<string, mixed> $overrides Values to override.
+ * @return array<string, mixed>
+ */
+ private function values( array $overrides = array() ): array {
+ return array_merge(
+ array(
+ 'contract_id' => 7,
+ 'sequence_no' => 2,
+ 'count' => 2,
+ 'period_start' => '2026-02-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'extension_slug' => 'lite',
+ 'plan_snapshot_id' => 11,
+ 'items_snapshot_id' => 12,
+ ),
+ $overrides
+ );
+ }
+
public function test_open_ended_policy_never_reaches_max_cycles(): void {
$policy = $this->policy( null );
@@ -71,8 +95,7 @@ class RenewalCalculatorTest extends TestCase {
public function test_next_bill_date_normalizes_non_utc_anchor_to_utc(): void {
$policy = $this->policy( null, 'day', 1 );
- // 2026-01-15 23:30 in a +05:00 zone is 2026-01-15 18:30 UTC; adding a
- // day lands on 2026-01-16 18:30 UTC.
+ // 2026-01-15 23:30 in a +05:00 zone is 2026-01-15 18:30 UTC; adding a day lands on 2026-01-16 18:30 UTC.
$anchor = new DateTimeImmutable( '2026-01-15 23:30:00', new DateTimeZone( '+05:00' ) );
$next = RenewalCalculator::next_bill_date( $policy, $anchor );
@@ -80,4 +103,70 @@ class RenewalCalculatorTest extends TestCase {
$this->assertSame( '2026-01-16 18:30:00', $next->format( 'Y-m-d H:i:s' ) );
$this->assertSame( 'UTC', $next->getTimezone()->getName() );
}
+
+ /**
+ * @testdox compute_next_cycle runs the period one cadence forward from the anchor.
+ */
+ public function test_compute_next_cycle_runs_one_cadence_forward(): void {
+ $cycle = RenewalCalculator::compute_next_cycle( $this->policy( null, 'month', 1 ), $this->values() );
+
+ // Monthly cadence: the period ends one month after its start - a real one-cadence
+ // period, never zero-duration.
+ $this->assertSame( '2026-02-15 00:00:00', $cycle->get_starts_at_gmt() );
+ $this->assertSame( '2026-03-15 00:00:00', $cycle->get_ends_at_gmt() );
+ $this->assertNotSame( $cycle->get_starts_at_gmt(), $cycle->get_ends_at_gmt() );
+ }
+
+ /**
+ * @testdox compute_next_cycle honours the cadence interval and period.
+ */
+ public function test_compute_next_cycle_honours_cadence(): void {
+ $cycle = RenewalCalculator::compute_next_cycle(
+ $this->policy( null, 'week', 2 ),
+ $this->values( array( 'period_start' => '2026-03-01 00:00:00' ) )
+ );
+
+ $this->assertSame( '2026-03-01 00:00:00', $cycle->get_starts_at_gmt() );
+ $this->assertSame( '2026-03-15 00:00:00', $cycle->get_ends_at_gmt() );
+ }
+
+ /**
+ * @testdox compute_next_cycle returns a pending cycle carrying the passed count, amount, currency, and snapshots.
+ */
+ public function test_compute_next_cycle_carries_passed_values(): void {
+ $cycle = RenewalCalculator::compute_next_cycle(
+ $this->policy( null, 'month', 1 ),
+ $this->values(
+ array(
+ 'count' => 5,
+ 'sequence_no' => 5,
+ 'expected_total' => '24.50',
+ 'currency' => 'EUR',
+ )
+ )
+ );
+
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertSame( 7, $cycle->get_contract_id() );
+ $this->assertSame( 5, $cycle->get_count() );
+ $this->assertSame( 5, $cycle->get_sequence_no() );
+ $this->assertEquals( 24.50, (float) $cycle->get_expected_total() );
+ $this->assertSame( 'EUR', $cycle->get_currency() );
+ $this->assertSame( 11, $cycle->get_plan_snapshot_id() );
+ $this->assertSame( 12, $cycle->get_items_snapshot_id() );
+ }
+
+ /**
+ * @testdox compute_next_cycle is calendar-aware for month-end roll-over.
+ */
+ public function test_compute_next_cycle_is_calendar_aware(): void {
+ $cycle = RenewalCalculator::compute_next_cycle(
+ $this->policy( null, 'month', 1 ),
+ $this->values( array( 'period_start' => '2026-01-31 00:00:00' ) )
+ );
+
+ // One month from 31 Jan rolls to 3 Mar (Feb has no 31st), via the billing policy.
+ $this->assertSame( '2026-01-31 00:00:00', $cycle->get_starts_at_gmt() );
+ $this->assertSame( '2026-03-03 00:00:00', $cycle->get_ends_at_gmt() );
+ }
}