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() );
+	}
 }