Commit 36e944da857 for woocommerce

commit 36e944da857c1ee0795b5f07adbc62dcb3d66303
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Fri Jun 19 17:46:45 2026 +0200

    Subscriptions engine: add the behavioral surface (#65849)

    - Add the contract status-transition machine - ContractStatus is the canonical guard and Contract::set_status() routes every change through it, so illegal moves throw
    - Add the gateway capability registry, split Core (pure declaration store) and Integration (WooCommerce-native resolution behind an always-final filter)
    - Add the renewal money-path: order-to-contract factory, the renewal engine and its Action Scheduler bridge (capability-gated, one job per contract, per-cycle idempotency, charge handed off to the registered gateway), and the pure Core renewal calculator
    - Harden entity, factory, and storage boundaries - reject a negative cycle_count on hydration, require a persisted order in the factory, and guard ContractRepository::update() against a stale id

diff --git a/packages/php/woocommerce-subscriptions-engine/.wp-env.json b/packages/php/woocommerce-subscriptions-engine/.wp-env.json
index 1ac6588b11f..6e395802b66 100644
--- a/packages/php/woocommerce-subscriptions-engine/.wp-env.json
+++ b/packages/php/woocommerce-subscriptions-engine/.wp-env.json
@@ -1,6 +1,7 @@
 {
 	"phpVersion": "8.3",
 	"plugins": [
+		"https://downloads.wordpress.org/plugin/woocommerce.zip",
 		"."
 	],
 	"config": {
@@ -18,6 +19,7 @@
 		"tests": {
 			"port": 8083,
 			"plugins": [
+				"https://downloads.wordpress.org/plugin/woocommerce.zip",
 				"."
 			],
 			"themes": [],
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-boundary-guards b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-boundary-guards
new file mode 100644
index 00000000000..76a6497357b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-boundary-guards
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Harden engine boundaries: reject negative cycle_count on Contract hydration, require a persisted order in ContractFactory, and guard ContractRepository::update() against a stale id / concurrent delete.
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-money-path b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-money-path
new file mode 100644
index 00000000000..3b86101b898
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-renewal-money-path
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add the renewal money-path: order-to-contract factory, the renewal engine (schedule, advance, charge, cancel) with its Action Scheduler bridge, the pure Core renewal calculator, and ContractRepository::update().
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-status-machine-capabilities b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-status-machine-capabilities
new file mode 100644
index 00000000000..09382f81758
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-status-machine-capabilities
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add the contract status-transition machine and the gateway capability registry.
diff --git a/packages/php/woocommerce-subscriptions-engine/package.json b/packages/php/woocommerce-subscriptions-engine/package.json
index 3b9fb08cfd4..19665133218 100644
--- a/packages/php/woocommerce-subscriptions-engine/package.json
+++ b/packages/php/woocommerce-subscriptions-engine/package.json
@@ -2,6 +2,7 @@
 	"name": "@automattic/woocommerce-subscriptions-engine",
 	"description": "Subscriptions engine for WooCommerce.",
 	"scripts": {
+		"changelog": "XDEBUG_MODE=off composer install --quiet && composer exec -- changelogger",
 		"update:php": "XDEBUG_MODE=off composer update --quiet",
 		"lint": "pnpm --if-present '/^lint:lang:.*$/'",
 		"lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index d131797dae9..e3c794fad4b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -322,8 +322,19 @@ final class Contract {
 	 * @param array<int, array<string, mixed>>    $items     Item rows.
 	 * @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
 	 * @param array<string, string>               $meta      Meta as key => value.
+	 * @throws DomainException If the stored cycle_count is negative.
 	 */
 	public static function from_storage( array $row, array $items = array(), array $addresses = array(), array $meta = array() ): self {
+		// Hydration is a trust boundary too: a corrupted or migrated row must not
+		// smuggle a negative cycle_count past the invariant set_cycle_count()
+		// enforces, since that would corrupt renewal cycle and idempotency math.
+		$cycle_count = (int) ( $row['cycle_count'] ?? 0 );
+		if ( $cycle_count < 0 ) {
+			throw new DomainException(
+				sprintf( 'Contract: stored cycle_count must be 0 or greater, got %d.', $cycle_count )
+			);
+		}
+
 		return new self(
 			array(
 				'id'                   => isset( $row['id'] ) ? (int) $row['id'] : null,
@@ -346,7 +357,7 @@ final class Contract {
 				'last_attempt_gmt'     => $row['last_attempt_gmt'] ?? null,
 				'trial_end_gmt'        => $row['trial_end_gmt'] ?? null,
 				'end_gmt'              => $row['end_gmt'] ?? null,
-				'cycle_count'          => (int) ( $row['cycle_count'] ?? 0 ),
+				'cycle_count'          => $cycle_count,
 				'schedule_source'      => (string) ( $row['schedule_source'] ?? self::SCHEDULE_SOURCE_PRIMITIVE ),
 				'items'                => $items,
 				'addresses'            => $addresses,
@@ -389,11 +400,7 @@ final class Contract {
 			return;
 		}

-		if ( ! ContractStatus::can_transition( $this->status, $status ) ) {
-			throw new DomainException(
-				sprintf( 'Contract: illegal status transition from "%s" to "%s".', $this->status, $status )
-			);
-		}
+		ContractStatus::assert_transition_allowed( $this->status, $status );

 		$this->status = $status;
 	}
@@ -458,6 +465,27 @@ final class Contract {
 		return $this->billing_total;
 	}

+	/**
+	 * Recurring discount per cycle (decimal-safe string).
+	 */
+	public function get_discount_total(): string {
+		return $this->discount_total;
+	}
+
+	/**
+	 * Recurring shipping per cycle (decimal-safe string).
+	 */
+	public function get_shipping_total(): string {
+		return $this->shipping_total;
+	}
+
+	/**
+	 * Recurring tax per cycle (decimal-safe string).
+	 */
+	public function get_tax_total(): string {
+		return $this->tax_total;
+	}
+
 	/**
 	 * Next renewal attempt, or null.
 	 */
@@ -474,6 +502,22 @@ final class Contract {
 		$this->next_payment_gmt = $next_payment_gmt;
 	}

+	/**
+	 * Last successful renewal payment, or null. GMT string.
+	 */
+	public function get_last_payment_gmt(): ?string {
+		return $this->last_payment_gmt;
+	}
+
+	/**
+	 * Set the last successful renewal payment timestamp.
+	 *
+	 * @param string|null $last_payment_gmt GMT string or null.
+	 */
+	public function set_last_payment_gmt( ?string $last_payment_gmt ): void {
+		$this->last_payment_gmt = $last_payment_gmt;
+	}
+
 	/**
 	 * Start timestamp (GMT string).
 	 */
@@ -488,6 +532,28 @@ final class Contract {
 		return $this->cycle_count;
 	}

+	/**
+	 * Set the count of successfully-paid renewal cycles.
+	 *
+	 * The renewal money-path advances this under a per-cycle idempotency guard,
+	 * so the read-modify-write happens once per cycle and a plain setter is
+	 * safe. An atomic, server-side increment becomes necessary once renewal
+	 * accounting is driven by concurrent gateway webhooks (the cycles/attempts
+	 * reshape); until then this is the simpler shape.
+	 *
+	 * @param int $cycle_count New cycle count.
+	 * @throws DomainException If `$cycle_count` is negative.
+	 */
+	public function set_cycle_count( int $cycle_count ): void {
+		if ( $cycle_count < 0 ) {
+			throw new DomainException(
+				sprintf( 'Contract: cycle_count must be 0 or greater, got %d.', $cycle_count )
+			);
+		}
+
+		$this->cycle_count = $cycle_count;
+	}
+
 	/**
 	 * Who runs renewals: 'primitive' or 'gateway'.
 	 */
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
index 981e9cc4d85..bf9fdcb55d6 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
@@ -12,6 +12,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

+use DomainException;
+
 defined( 'ABSPATH' ) || exit;

 /**
@@ -61,10 +63,17 @@ final class ContractStatus {
 	/**
 	 * Whether a contract may move from `$from` to `$to`.
 	 *
+	 * Unknown source or target statuses are reported as not allowed, so a row
+	 * that has drifted into an unrecognized state cannot be transitioned out of
+	 * a value we do not know how to reason about. Same-status calls
+	 * (`active` -> `active`) report false here; {@see Contract::set_status()}
+	 * short-circuits no-ops before consulting this table so they do not surface
+	 * as exceptions to callers.
+	 *
 	 * @param string $from Current status.
 	 * @param string $to   Target status.
 	 */
-	public static function can_transition( string $from, string $to ): bool {
+	public static function is_transition_allowed( string $from, string $to ): bool {
 		if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
 			return false;
 		}
@@ -72,6 +81,37 @@ final class ContractStatus {
 		return in_array( $to, self::transitions()[ $from ], true );
 	}

+	/**
+	 * Whether a contract may move from `$from` to `$to`.
+	 *
+	 * Alias of {@see self::is_transition_allowed()}.
+	 *
+	 * @param string $from Current status.
+	 * @param string $to   Target status.
+	 */
+	public static function can_transition( string $from, string $to ): bool {
+		return self::is_transition_allowed( $from, $to );
+	}
+
+	/**
+	 * Throw if `$from` -> `$to` is not an allowed transition.
+	 *
+	 * The canonical enforcement entry point: every status change flows through
+	 * here before the new status is applied, which makes "no nonsense states" a
+	 * structural guarantee rather than a code-review aspiration.
+	 *
+	 * @param string $from Current status.
+	 * @param string $to   Target status.
+	 * @throws DomainException When the transition is rejected by {@see self::is_transition_allowed()}.
+	 */
+	public static function assert_transition_allowed( string $from, string $to ): void {
+		if ( ! self::is_transition_allowed( $from, $to ) ) {
+			throw new DomainException(
+				sprintf( 'ContractStatus: illegal status transition from "%s" to "%s".', $from, $to )
+			);
+		}
+	}
+
 	/**
 	 * Allowed transitions: current status => list of reachable statuses.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Gateway/GatewayCapabilities.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Gateway/GatewayCapabilities.php
new file mode 100644
index 00000000000..dd72b6f0479
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Gateway/GatewayCapabilities.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * GatewayCapabilities - the pure store of declared gateway capabilities.
+ *
+ * Payment gateways tell the engine which subscription features they can handle:
+ * "I can process recurring charges," "I tolerate variable amounts," "I run my
+ * own renewal schedule." This Core class owns the in-memory declarations and
+ * the predicate over them; the WordPress-facing entry point lives in
+ * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry},
+ * which delegates registration here and layers the live-gateway and filter
+ * resolution steps on top.
+ *
+ * Core zone: WordPress-free by design. No WP/Woo symbols, no time functions.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway;
+
+use InvalidArgumentException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Pure storage and predicate for gateway capability declarations.
+ */
+final class GatewayCapabilities {
+
+	/**
+	 * Gateway can process engine-scheduled recurring charges.
+	 *
+	 * The most fundamental flag: an engine-scheduled contract on a gateway
+	 * lacking this capability would create renewals nobody runs.
+	 */
+	const RECURRING = 'recurring';
+
+	/**
+	 * Customer can change the payment method on an active contract. All modern
+	 * gateways support this; the flag exists for the rare manual-only case.
+	 */
+	const PAYMENT_METHOD_CHANGE = 'payment_method_change';
+
+	/**
+	 * Gateway tolerates variable charge amounts (volume-tier upgrades, tax-rate
+	 * changes, prorated mid-cycle adjustments). Flag the difference so
+	 * amount-changing flows can refuse on an incapable gateway rather than
+	 * silently mis-charging.
+	 */
+	const AMOUNT_CHANGES = 'amount_changes';
+
+	/**
+	 * One customer can hold N active contracts. Default for modern gateways;
+	 * the flag exists for single-mandate gateways.
+	 */
+	const MULTIPLE_PER_CUSTOMER = 'multiple_per_customer';
+
+	/**
+	 * Gateway schedules and fires renewals itself; the engine only tracks
+	 * metadata. Pairs with the gateway `schedule_source` at the contract level -
+	 * a gateway can declare this capability without every contract on it being
+	 * gateway-scheduled.
+	 */
+	const GATEWAY_SCHEDULED_RENEWALS = 'gateway_scheduled_renewals';
+
+	/**
+	 * In-memory declarations, keyed by gateway id => list of declared capability
+	 * flag strings (de-duplicated and reindexed).
+	 *
+	 * Static (not instance state) because the public registration API is itself
+	 * static - every consumer reaches the registry by class name, not via an
+	 * injected instance.
+	 *
+	 * @var array<string, array<int, string>>
+	 */
+	private static $declarations = array();
+
+	/**
+	 * Every recognised capability flag.
+	 *
+	 * Public so test code, admin tooling, and future capability-matrix UIs can
+	 * iterate the canonical list without hard-coding the constants.
+	 *
+	 * @return array<int, string>
+	 */
+	public static function known_capabilities(): array {
+		return array(
+			self::RECURRING,
+			self::PAYMENT_METHOD_CHANGE,
+			self::AMOUNT_CHANGES,
+			self::MULTIPLE_PER_CUSTOMER,
+			self::GATEWAY_SCHEDULED_RENEWALS,
+		);
+	}
+
+	/**
+	 * Register a gateway's declared capabilities.
+	 *
+	 * Each entry in `$capabilities` must be one of the flag constants on this
+	 * class; an unknown flag throws so a typo surfaces at registration rather
+	 * than silently defaulting to "unsupported" later.
+	 *
+	 * Re-declaration replaces: if a gateway declares twice, the second call wins
+	 * outright - we do not merge. Merging would make "I removed this capability"
+	 * impossible to express, and it mirrors the replace-not-merge semantics of
+	 * WooCommerce's own feature-compatibility declarations.
+	 *
+	 * @param string             $gateway_id   Gateway identifier.
+	 * @param array<int, string> $capabilities Capability flag strings; each must be one of {@see self::known_capabilities()}.
+	 * @throws InvalidArgumentException When `$capabilities` contains a flag not in {@see self::known_capabilities()}.
+	 */
+	public static function declare( string $gateway_id, array $capabilities ): void {
+		$known = self::known_capabilities();
+		foreach ( $capabilities as $capability ) {
+			if ( ! in_array( $capability, $known, true ) ) {
+				throw new InvalidArgumentException(
+					sprintf(
+						'GatewayCapabilities: unknown capability flag "%s". Expected one of: %s.',
+						is_string( $capability ) ? $capability : gettype( $capability ),
+						implode( ', ', $known )
+					)
+				);
+			}
+		}
+
+		// De-dupe and reindex before storing: the public API documents a "list
+		// of strings" shape, so double-declarations should not leak through
+		// lookups indistinguishably.
+		self::$declarations[ $gateway_id ] = array_values( array_unique( $capabilities ) );
+	}
+
+	/**
+	 * Whether `$gateway_id` has declared `$capability`.
+	 *
+	 * A pure predicate over the static declarations only - it does not consult
+	 * any live gateway or filter. Returns false for unknown gateway ids and
+	 * unknown capability strings; the strict path is {@see self::declare()},
+	 * which throws on typos at registration.
+	 *
+	 * @param string $gateway_id Gateway identifier.
+	 * @param string $capability Capability flag.
+	 */
+	public static function is_declared( string $gateway_id, string $capability ): bool {
+		$declared = self::$declarations[ $gateway_id ] ?? array();
+
+		return in_array( $capability, $declared, true );
+	}
+
+	/**
+	 * Clear every declaration.
+	 *
+	 * @internal Public only so test setUp can isolate per-test state. Not part
+	 *           of the consumer API.
+	 */
+	public static function reset(): void {
+		self::$declarations = array();
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
new file mode 100644
index 00000000000..3ea8df4a4c5
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Renewal/RenewalCalculator.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * RenewalCalculator - pure renewal date math and the cycle-advance decision.
+ *
+ * Answers two 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,
+ *
+ *   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`?
+ *
+ * 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.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal;
+
+use DateTimeImmutable;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Pure renewal calculator.
+ *
+ * Stateless: every method is static and derives its answer solely from its
+ * arguments. The integration layer (`Integration\Renewal\RenewalEngine`) reads
+ * the contract row, calls into here for the decision, then writes the result
+ * back and talks to Action Scheduler / the gateway.
+ */
+final class RenewalCalculator {
+
+	/**
+	 * Whether a contract that has paid `$cycle_count` cycles has reached the
+	 * policy's hard end.
+	 *
+	 * `max_cycles` counts total billed cycles. A contract is terminal once its
+	 * paid-cycle count is at or past `max_cycles`. Open-ended policies
+	 * (`max_cycles === null`) never reach a hard end this way - they run until
+	 * cancelled.
+	 *
+	 * The comparison is `>=`, not `===`: a contract that somehow over-counted
+	 * (a replayed webhook that slipped the idempotency gate, a migrated row)
+	 * should still be treated as terminal rather than billing forever past its
+	 * cap.
+	 *
+	 * @param BillingPolicy $policy      The plan's billing policy.
+	 * @param int           $cycle_count Count of successfully-billed cycles so far.
+	 */
+	public static function has_reached_max_cycles( BillingPolicy $policy, int $cycle_count ): bool {
+		$max_cycles = $policy->get_max_cycles();
+
+		if ( null === $max_cycles ) {
+			return false;
+		}
+
+		return $cycle_count >= $max_cycles;
+	}
+
+	/**
+	 * Compute the next bill date for a contract advancing 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.
+	 *
+	 * @param BillingPolicy     $policy               The plan's billing policy.
+	 * @param DateTimeImmutable $current_period_start The anchor the next cycle is measured from.
+	 * @return DateTimeImmutable The next bill date, in UTC.
+	 */
+	public static function next_bill_date( BillingPolicy $policy, DateTimeImmutable $current_period_start ): DateTimeImmutable {
+		return $policy->compute_next_renewal_from( $current_period_start );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
index 93727b6c4e7..74775ccaca4 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Bootstrap.php
@@ -14,6 +14,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;

+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;

 defined( 'ABSPATH' ) || exit;
@@ -41,6 +43,13 @@ final class Bootstrap {

 		self::$initialized = true;

+		CapabilityRegistry::init();
+
+		// Register the Action Scheduler callback that dispatches a due renewal
+		// back into the engine. Must run on every boot (not just activation) so
+		// AS can fire scheduled renewals.
+		RenewalEngine::register_hooks();
+
 		if ( did_action( 'init' ) ) {
 			self::maybe_install_schema();
 		} else {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
new file mode 100644
index 00000000000..19f909b65dc
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * ContractFactory - builds and persists a Contract from a completed order.
+ *
+ * This is the entry point a consumer's checkout handler calls once an order
+ * that contains a subscription product is paid: hand it the order plus the
+ * selling {@see Plan} the customer chose, and it
+ *
+ *   1. builds a Core {@see Contract} from the order's customer / currency /
+ *      payment / totals / line items / addresses,
+ *   2. seeds the first renewal date from the plan's {@see BillingPolicy}
+ *      (honouring a native trial),
+ *   3. persists it via {@see ContractRepository::insert()}, and
+ *   4. links order <-> contract in both directions (the contract row's
+ *      `origin_order_id` column plus the order-side {@see OrderLinkage} meta).
+ *
+ * Returns the persisted Contract. Scheduling the first renewal is a separate
+ * step the caller drives through {@see RenewalEngine::schedule()} - the factory
+ * builds the contract and sets `next_payment_gmt`; it does not enqueue the
+ * Action Scheduler row, so a caller can create a contract without arming the
+ * money-path (e.g. a gateway-scheduled contract).
+ *
+ * Integration zone: WordPress-native. Reads a live `WC_Order`; the order never
+ * crosses into Core - only the snapshot values pulled off it do.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
+
+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\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Order -> contract factory.
+ */
+final class ContractFactory {
+
+	/**
+	 * The repository the factory persists through.
+	 *
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * Build a factory that persists through the given repository.
+	 *
+	 * @param ContractRepository|null $contracts Repository to persist through; a
+	 *                                           default instance is created when
+	 *                                           omitted.
+	 */
+	public function __construct( ?ContractRepository $contracts = null ) {
+		$this->contracts = $contracts ?? new ContractRepository();
+	}
+
+	/**
+	 * Build, persist, and link a contract for `$order` on `$plan`.
+	 *
+	 * Recurring totals are seeded from the order's totals on the assumption
+	 * that the first cycle's price equals the recurring price; a caller that
+	 * already knows the recurring totals (sign-up fees, first-cycle discounts)
+	 * passes them via `$overrides`, which are merged over the order-derived
+	 * defaults before the contract is built.
+	 *
+	 * The first `next_payment_gmt` comes from
+	 * {@see BillingPolicy::compute_first_renewal_from()} anchored on the order's
+	 * paid time (`date_paid`, falling back to "now"), so a native trial delays
+	 * the first bill correctly. A caller can override it via
+	 * `$overrides['next_payment_gmt']`.
+	 *
+	 * @param WC_Order             $order     The paid checkout order.
+	 * @param Plan                 $plan      The selling plan the customer chose. Must be persisted (have an id).
+	 * @param array<string, mixed> $overrides Optional explicit values for any Contract::create() field.
+	 * @return Contract The persisted contract, with its id assigned.
+	 * @throws \RuntimeException If the plan or order has no id, or the insert fails.
+	 */
+	public function create_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): Contract {
+		$plan_id = $plan->get_id();
+		if ( null === $plan_id ) {
+			throw new \RuntimeException( 'ContractFactory::create_from_order(): the selling plan must be persisted (have an id) before a contract can reference it.' );
+		}
+
+		// An unsaved order reports id 0; persisting origin_order_id => 0 would link
+		// the contract to a non-existent order. Require a saved order up front.
+		if ( ! $order->get_id() ) {
+			throw new \RuntimeException( 'ContractFactory::create_from_order(): the order must be persisted (have an id) before a contract can link to it.' );
+		}
+
+		$now       = gmdate( 'Y-m-d H:i:s' );
+		$paid_date = $order->get_date_paid();
+		$anchor    = null !== $paid_date
+			? new DateTimeImmutable( '@' . $paid_date->getTimestamp() )
+			: new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) );
+
+		$next_payment = $plan->get_billing_policy()
+			->compute_first_renewal_from( $anchor )
+			->format( 'Y-m-d H:i:s' );
+
+		$defaults = array(
+			'customer_id'          => $order->get_customer_id(),
+			'currency'             => $order->get_currency(),
+			'selling_plan_id'      => $plan_id,
+			'origin_order_id'      => $order->get_id(),
+			'extension_slug'       => $plan->get_extension_slug(),
+			'payment_method'       => '' !== $order->get_payment_method() ? $order->get_payment_method() : null,
+			'payment_method_title' => '' !== $order->get_payment_method_title() ? $order->get_payment_method_title() : null,
+			'payment_token_id'     => $this->extract_payment_token_id( $order ),
+			'billing_total'        => (string) $order->get_total(),
+			'discount_total'       => (string) $order->get_total_discount(),
+			'shipping_total'       => (string) $order->get_shipping_total(),
+			'tax_total'            => (string) $order->get_total_tax(),
+			'start_gmt'            => $now,
+			'next_payment_gmt'     => $next_payment,
+			'items'                => $this->map_items( $order ),
+			'addresses'            => $this->map_addresses( $order ),
+			'meta'                 => array(),
+		);
+
+		$contract = Contract::create( array_merge( $defaults, $overrides ) );
+
+		$id = $this->contracts->insert( $contract );
+
+		$this->tag_origin_order( $order, $id );
+
+		return $contract;
+	}
+
+	/**
+	 * Tag `$order` with the parent-relation meta for `$contract_id`.
+	 *
+	 * Best-effort: the contract row is already persisted and carries the
+	 * `origin_order_id` FK, so a failure here (a save listener throwing, a DB
+	 * hiccup) is logged and swallowed rather than failing contract creation -
+	 * the reverse lookup can be healed from the FK later. Mirrors the order
+	 * tagging the renewal engine applies to renewal orders.
+	 *
+	 * @param WC_Order $order       Order to tag.
+	 * @param int      $contract_id Contract id to write into the order meta.
+	 */
+	private function tag_origin_order( WC_Order $order, int $contract_id ): void {
+		try {
+			$order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
+			$order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_PARENT );
+			$order->save();
+		} catch ( Throwable $e ) {
+			wc_get_logger()->warning(
+				sprintf(
+					'ContractFactory: failed to tag origin order %d for contract %d: %s. The contract is persisted; the order-side link can be rebuilt from the contract row.',
+					$order->get_id(),
+					$contract_id,
+					$e->getMessage()
+				),
+				array(
+					'source'      => 'woocommerce-subscriptions-engine',
+					'contract_id' => $contract_id,
+					'order_id'    => $order->get_id(),
+				)
+			);
+		}
+	}
+
+	/**
+	 * Map the order's line items to the contract item-row shape.
+	 *
+	 * Only `line_item` rows are carried onto the contract today (fees /
+	 * shipping / tax are reconstructed from the contract totals at renewal
+	 * time). The renewal-order builder clones the origin order's items, so the
+	 * contract items are a snapshot for inspection rather than the renewal
+	 * source of truth.
+	 *
+	 * @param WC_Order $order The order to read items from.
+	 * @return array<int, array<string, mixed>>
+	 */
+	private function map_items( WC_Order $order ): array {
+		$items = array();
+
+		foreach ( $order->get_items() as $item ) {
+			if ( ! $item instanceof WC_Order_Item_Product ) {
+				continue;
+			}
+
+			$items[] = array(
+				'item_name'    => $item->get_name(),
+				'item_type'    => 'line_item',
+				'product_id'   => $item->get_product_id(),
+				'variation_id' => $item->get_variation_id(),
+				'quantity'     => (string) $item->get_quantity(),
+				'subtotal'     => (string) $item->get_subtotal(),
+				'total'        => (string) $item->get_total(),
+				'taxes'        => $item->get_taxes(),
+			);
+		}
+
+		return $items;
+	}
+
+	/**
+	 * Map the order's billing and shipping addresses to the contract shape.
+	 *
+	 * @param WC_Order $order The order to read addresses from.
+	 * @return array<string, array<string, mixed>>
+	 */
+	private function map_addresses( WC_Order $order ): array {
+		return array(
+			Contract::ADDRESS_BILLING  => $order->get_address( 'billing' ),
+			Contract::ADDRESS_SHIPPING => $order->get_address( 'shipping' ),
+		);
+	}
+
+	/**
+	 * Best-effort extraction of the payment-token id from `$order`.
+	 *
+	 * Reads WooCommerce's per-order payment tokens, populated when a gateway
+	 * calls `$order->add_payment_token()` at checkout. WooCommerce's typical
+	 * pattern is one token per order with the most recently attached entry
+	 * being the one charged, so we read the last entry. Returns null when no
+	 * token is resolvable (manual gateways, or gateways that store their token
+	 * reference elsewhere) - the contract is then created without a token and a
+	 * later payment-method-change flow can attach one.
+	 *
+	 * @param WC_Order $order Order to read the token from.
+	 * @return int|null Token id, or null when none is resolvable.
+	 */
+	private function extract_payment_token_id( WC_Order $order ): ?int {
+		$tokens = $order->get_payment_tokens();
+		if ( ! empty( $tokens ) ) {
+			$id = (int) end( $tokens );
+			if ( $id > 0 ) {
+				return $id;
+			}
+		}
+
+		return null;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
new file mode 100644
index 00000000000..87ceaee9c68
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * OrderLinkage - the order-side meta keys that link orders to contracts.
+ *
+ * The order <-> contract relationship is recorded on order meta so it is
+ * queryable from the order side without loading the contract:
+ *
+ *  - `_subscription_contract_id`   (int)    - which contract this order belongs to.
+ *  - `_subscription_relation_type` (string) - `parent | renewal | switch | resubscribe`.
+ *
+ * The contract row carries the reverse direction (`origin_order_id`); these
+ * order-meta keys make the relationship symmetric. The engine owns these keys;
+ * consumers that need to detect "is this a renewal order?" read them through
+ * this class rather than hard-coding the strings.
+ *
+ * Integration zone: WordPress-native. The keys are written to WooCommerce
+ * order meta (`WC_Order::update_meta_data()`), which works under both HPOS and
+ * the legacy CPT order store.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
+
+use InvalidArgumentException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Contract <-> order meta-key catalogue.
+ */
+final class OrderLinkage {
+
+	/**
+	 * Order meta key holding the contract id this order belongs to.
+	 *
+	 * Stored as a stringified integer (order meta is a flat string table). Read
+	 * via `(int) $order->get_meta( OrderLinkage::META_CONTRACT_ID )`.
+	 */
+	const META_CONTRACT_ID = '_subscription_contract_id';
+
+	/**
+	 * Order meta key holding the relation type - see the `RELATION_*` constants.
+	 */
+	const META_RELATION_TYPE = '_subscription_relation_type';
+
+	/**
+	 * The order whose checkout created the contract - the contract row's
+	 * `origin_order_id`. Tagged on the order side too so the relationship is
+	 * queryable from either direction.
+	 */
+	const RELATION_PARENT = 'parent';
+
+	/**
+	 * A renewal order - created by the renewal engine when a cycle bills.
+	 */
+	const RELATION_RENEWAL = 'renewal';
+
+	/**
+	 * A switch order - customer moved between plans (later milestone).
+	 */
+	const RELATION_SWITCH = 'switch';
+
+	/**
+	 * A resubscribe order - customer restarted a previously-cancelled contract
+	 * (later milestone).
+	 */
+	const RELATION_RESUBSCRIBE = 'resubscribe';
+
+	/**
+	 * All recognized relation types.
+	 *
+	 * @return array<int, string>
+	 */
+	public static function relation_types(): array {
+		return array(
+			self::RELATION_PARENT,
+			self::RELATION_RENEWAL,
+			self::RELATION_SWITCH,
+			self::RELATION_RESUBSCRIBE,
+		);
+	}
+
+	/**
+	 * Throw if `$relation` is not one of the known relation types.
+	 *
+	 * Centralising the check keeps callers from querying for a typoed relation
+	 * (`'renewals'`, `'parent_order'`) and silently getting an empty result.
+	 *
+	 * @param string $relation Candidate relation type.
+	 * @throws InvalidArgumentException If `$relation` is not recognized.
+	 */
+	public static function assert_relation( string $relation ): void {
+		if ( ! in_array( $relation, self::relation_types(), true ) ) {
+			throw new InvalidArgumentException(
+				sprintf(
+					'Unknown contract-order relation type: "%s". Expected one of: %s.',
+					esc_html( $relation ),
+					esc_html( implode( ', ', self::relation_types() ) )
+				)
+			);
+		}
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
new file mode 100644
index 00000000000..158e2d3c919
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Gateway/CapabilityRegistry.php
@@ -0,0 +1,199 @@
+<?php
+/**
+ * CapabilityRegistry - the WordPress-facing gateway capability entry point.
+ *
+ * Payment gateways declare their subscription capabilities here (the engine's
+ * counterpart to WooCommerce's feature-compatibility declarations), and the
+ * engine reads the declarations to gate behavior such as renewal scheduling and
+ * payment-method change. This class is the public, gateway-author-facing
+ * surface; it delegates the actual storage to the WordPress-free Core class
+ * {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities}
+ * and adds the live-gateway lookup, the override filter, and the ready-hook
+ * wiring on top.
+ *
+ * Integration zone: WordPress-native. May use WooCommerce.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway;
+
+use WC_Order;
+use WC_Payment_Gateway;
+use WC_Payment_Gateways;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public capability registry - gateway-author-facing.
+ */
+final class CapabilityRegistry {
+
+	/**
+	 * Filter that always has the final word on a capability check.
+	 *
+	 * Receives the result of the static-declaration and live-gateway steps plus
+	 * the optional order context, and returns the final value. Runtime-injection
+	 * gateways (where capability resolution depends on the merchant account
+	 * routing the order) flip a capability on or off here per order.
+	 */
+	const CAPABILITY_CHECK_FILTER = 'woocommerce_subscriptions_engine_gateway_capability_check';
+
+	/**
+	 * Action fired once capability resolution is stable for the request.
+	 *
+	 * Gateways declare in the canonical declaration window
+	 * (`before_woocommerce_init`); WooCommerce finishes loading afterwards.
+	 * Consumers that resolve capabilities should wait for this action so the
+	 * live-gateway step has a populated registry to read.
+	 */
+	const CAPABILITIES_READY_ACTION = 'woocommerce_subscriptions_engine_capabilities_ready';
+
+	/**
+	 * `woocommerce_loaded` priority for the ready dispatch.
+	 *
+	 * Set after the priority used by gateway integration frameworks that inject
+	 * capability flags onto the live gateway instance, so the live-gateway step
+	 * sees those flags by the time the ready action fires.
+	 */
+	const READY_HOOK_PRIORITY = 20;
+
+	/**
+	 * Whether hooks have already been registered, to keep registration
+	 * idempotent when more than one consumer boots the engine in one request.
+	 *
+	 * @var bool
+	 */
+	private static $initialized = false;
+
+	/**
+	 * Wire the ready dispatch onto `woocommerce_loaded`.
+	 *
+	 * Idempotent: safe to call from more than one consumer's boot.
+	 */
+	public static function init(): void {
+		if ( self::$initialized ) {
+			return;
+		}
+
+		self::$initialized = true;
+
+		add_action(
+			'woocommerce_loaded',
+			static function (): void {
+				do_action( self::CAPABILITIES_READY_ACTION );
+			},
+			self::READY_HOOK_PRIORITY
+		);
+	}
+
+	/**
+	 * Register a gateway's declared capabilities.
+	 *
+	 * Call from a gateway's `before_woocommerce_init` hook. Each entry must be
+	 * one of the flag constants on {@see GatewayCapabilities}; an unknown flag
+	 * throws there so a typo surfaces at registration. Re-declaration replaces.
+	 *
+	 * @param string             $gateway_id   Gateway identifier (matches `WC_Payment_Gateway::$id`).
+	 * @param array<int, string> $capabilities Capability flag strings.
+	 */
+	public static function declare_compatibility( string $gateway_id, array $capabilities ): void {
+		GatewayCapabilities::declare( $gateway_id, $capabilities );
+	}
+
+	/**
+	 * Whether `$gateway_id` supports `$capability`.
+	 *
+	 * Resolution chain:
+	 *
+	 *  1. Static declarations via {@see GatewayCapabilities::is_declared()} - the
+	 *     most explicit signal: the gateway author told us up front.
+	 *  2. The live gateway instance's `$supports` array, for frameworks that
+	 *     inject capability flags onto the instance during their own init rather
+	 *     than at class-load time. Skipped silently when WooCommerce is not
+	 *     loaded yet or the gateway is not in the registry.
+	 *  3. The {@see self::CAPABILITY_CHECK_FILTER} filter, which is always the
+	 *     final word: it receives the steps-1-2 result and the `$context` order,
+	 *     and a filter returning false overrides an earlier true, and vice versa.
+	 *  4. Cast to bool.
+	 *
+	 * Returns false (does not throw) for unknown gateway ids and unknown
+	 * capability strings - this is a predicate, not a validation path.
+	 *
+	 * @param string        $gateway_id Gateway identifier.
+	 * @param string        $capability Capability flag.
+	 * @param WC_Order|null $context    Optional order for per-order resolution.
+	 */
+	public static function supports( string $gateway_id, string $capability, ?WC_Order $context = null ): bool {
+		// Step 1: static declarations.
+		$current = GatewayCapabilities::is_declared( $gateway_id, $capability );
+
+		// Step 2: live gateway instance `$supports` array. Only consult it when
+		// step 1 has not already resolved true - the filter still gets a chance
+		// to flip the answer regardless.
+		if ( ! $current ) {
+			$current = self::gateway_instance_supports( $gateway_id, $capability );
+		}
+
+		/**
+		 * Filters the resolved capability check before `supports()` returns.
+		 *
+		 * The final word: a filter returning false overrides a true from the
+		 * static-declaration or live-gateway steps, and vice versa.
+		 *
+		 * @param bool          $current    Result of the static-declaration and live-gateway steps.
+		 * @param string        $gateway_id Gateway being checked.
+		 * @param string        $capability Capability being checked.
+		 * @param WC_Order|null $context    Per-order context, or null for gateway-global checks.
+		 */
+		$resolved = apply_filters( self::CAPABILITY_CHECK_FILTER, $current, $gateway_id, $capability, $context );
+
+		return (bool) $resolved;
+	}
+
+	/**
+	 * Read the live gateway instance's `$supports` array (resolution step 2).
+	 *
+	 * WooCommerce initializes its gateway registry on `woocommerce_loaded`, so
+	 * the lookup is empty for callers running before that fires. That is not an
+	 * error: "WooCommerce is not loaded yet" reduces cleanly to "the gateway
+	 * does not claim that capability via the instance path."
+	 *
+	 * @param string $gateway_id Gateway identifier.
+	 * @param string $capability Capability flag.
+	 */
+	private static function gateway_instance_supports( string $gateway_id, string $capability ): bool {
+		if ( ! function_exists( 'WC' ) ) {
+			return false;
+		}
+
+		$wc = WC();
+		if ( ! is_object( $wc ) || ! method_exists( $wc, 'payment_gateways' ) ) {
+			return false;
+		}
+
+		$registry = $wc->payment_gateways();
+		if ( ! $registry instanceof WC_Payment_Gateways ) {
+			return false;
+		}
+
+		// `payment_gateways()` returns an associative array keyed by gateway id;
+		// it is empty until the gateway registry initializes.
+		$gateways = $registry->payment_gateways();
+		$gateway  = $gateways[ $gateway_id ] ?? null;
+
+		if ( ! $gateway instanceof WC_Payment_Gateway ) {
+			return false;
+		}
+
+		// `$supports` is documented as an array in core; defensively narrow
+		// non-array values so a misconfigured gateway does not throw out of
+		// `in_array`.
+		$supports = is_array( $gateway->supports ) ? $gateway->supports : array();
+
+		return in_array( $capability, $supports, true );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
new file mode 100644
index 00000000000..4ddff6fdb7b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -0,0 +1,560 @@
+<?php
+/**
+ * RenewalEngine - the seam between consumers and the renewal money-path.
+ *
+ * `schedule()` is what a consumer (or the checkout factory's caller) invokes
+ * when a contract's next-payment date is set or moved; `process_due()` is what
+ * Action Scheduler calls back into when the scheduled moment arrives. Action
+ * Scheduler is the wrong thing to expose directly - hook names, group
+ * conventions, and dedup behaviour are implementation choices the engine should
+ * be free to change - so this class wraps them and adds the contract-aware
+ * semantics (capability gating, the advance, the renewal order, the charge).
+ *
+ * The AS coupling lives in {@see RenewalScheduler}; this class delegates to it.
+ * The pure date math lives in {@see RenewalCalculator} (Core); this class reads
+ * the contract row, calls Core for the decision, then writes the result back.
+ *
+ * POC shape: one AS job per contract, advance-on-fire. The long-term batch
+ * dispatcher and the split of attempt-vs-success accounting arrive with the
+ * cycles/attempts reshape - see the package architecture notes. Until then this
+ * advances the contract clock at fire time so the chain keeps moving.
+ *
+ * Integration zone: WordPress-native. Action Scheduler, WC orders, gateways.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Throwable;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
+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;
+
+/**
+ * Renewal engine - schedule, advance, charge, cancel.
+ */
+final class RenewalEngine {
+
+	/**
+	 * Action fired after a contract is scheduled, with `( $contract, $when )`.
+	 * Listeners observe a scheduled state, not an in-flight one.
+	 */
+	const RENEWAL_SCHEDULED_ACTION = 'woocommerce_subscriptions_engine_renewal_scheduled';
+
+	/**
+	 * Action fired after a renewal order is created, with `( $renewal_order, $contract )`.
+	 */
+	const RENEWAL_ORDER_CREATED_ACTION = 'woocommerce_subscriptions_engine_renewal_order_created';
+
+	/**
+	 * Logger source tag.
+	 */
+	const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
+	/**
+	 * Repository for loading and persisting contracts.
+	 *
+	 * @var ContractRepository
+	 */
+	private $contracts;
+
+	/**
+	 * Repository for loading plans (the BillingPolicy 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, ?PlanRepository $plans = null ) {
+		$this->contracts = $contracts ?? new ContractRepository();
+		$this->plans     = $plans ?? new PlanRepository();
+	}
+
+	/**
+	 * Register the Action Scheduler callback.
+	 *
+	 * Must run on every page load (not just activation) so AS can dispatch a
+	 * due action back into {@see self::process_due()}. Wired from
+	 * {@see \Automattic\WooCommerce\SubscriptionsEngine\Integration\Bootstrap}.
+	 */
+	public static function register_hooks(): void {
+		add_action( RenewalScheduler::HOOK, array( __CLASS__, 'handle_due_action' ), 10, 1 );
+	}
+
+	/**
+	 * Action Scheduler dispatch entry point - fires when a renewal is due.
+	 *
+	 * Static so it can be registered as a plain callback; constructs an engine
+	 * with default repositories and routes through the instance `process_due()`
+	 * so the dispatch path and any synchronous test driver share one code path.
+	 *
+	 * @param int $contract_id Contract whose renewal is firing.
+	 */
+	public static function handle_due_action( int $contract_id ): void {
+		( new self() )->process_due( $contract_id );
+	}
+
+	/**
+	 * Schedule (or re-schedule) the next renewal for `$contract` at its
+	 * `next_payment_gmt`.
+	 *
+	 * Re-scheduling replaces: at most one pending AS row exists per contract.
+	 * The clear-then-enqueue is unconditional, so callers moving the date
+	 * forward just call `schedule()` again.
+	 *
+	 * **Capability gate (the schedule-time guard).** A primitive-scheduled
+	 * contract is only enqueued when its gateway declares the `recurring`
+	 * capability via {@see CapabilityRegistry::supports()}. An incapable gateway
+	 * would create renewals nothing can charge, so we refuse at the boundary
+	 * (log + no enqueue) rather than failing later on a customer-facing order.
+	 * Gateway-scheduled contracts (`schedule_source = gateway`) are never
+	 * enqueued here - the gateway runs its own schedule; any stale primitive row
+	 * is cleared.
+	 *
+	 * Does nothing when the contract has no `next_payment_gmt` (nothing to
+	 * anchor on) - any stale row is cleared.
+	 *
+	 * @param Contract $contract Contract to schedule. Must have an id.
+	 * @return bool True when an AS row was enqueued; false when scheduling was
+	 *              skipped (gateway-scheduled, incapable gateway, no date, no id).
+	 */
+	public function schedule( Contract $contract ): bool {
+		$id = $contract->get_id();
+		if ( null === $id ) {
+			return false;
+		}
+
+		// Gateway-scheduled: the gateway owns the schedule. Clear any stale
+		// primitive row and bail.
+		if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
+			RenewalScheduler::unschedule( $id );
+			return false;
+		}
+
+		$next_payment_gmt = $contract->get_next_payment_gmt();
+		if ( null === $next_payment_gmt ) {
+			RenewalScheduler::unschedule( $id );
+			return false;
+		}
+
+		// Schedule-time capability gate.
+		$gateway_id = $contract->get_payment_instrument()->get_gateway();
+		if ( null === $gateway_id || '' === $gateway_id || ! CapabilityRegistry::supports( (string) $gateway_id, GatewayCapabilities::RECURRING ) ) {
+			RenewalScheduler::unschedule( $id );
+			wc_get_logger()->warning(
+				sprintf(
+					'RenewalEngine::schedule(): not scheduling contract %d - gateway "%s" does not declare the "recurring" capability. Declare it via CapabilityRegistry, or set the contract to gateway-scheduled if the gateway runs its own renewals.',
+					$id,
+					(string) $gateway_id
+				),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $id,
+					'gateway_id'  => (string) $gateway_id,
+				)
+			);
+			return false;
+		}
+
+		$when = new DateTimeImmutable( $next_payment_gmt, new DateTimeZone( 'UTC' ) );
+
+		// Clear-then-enqueue keeps the single-row-per-contract invariant: AS
+		// does not dedup on hook+args, so without the clear a re-schedule would
+		// leave two rows and fire twice.
+		RenewalScheduler::unschedule( $id );
+		RenewalScheduler::schedule( $id, $when );
+
+		do_action( self::RENEWAL_SCHEDULED_ACTION, $contract, $when );
+
+		return true;
+	}
+
+	/**
+	 * Run the renewal due for `$contract_id`. Fired by the AS hook.
+	 *
+	 * Steps:
+	 *  1. Load the contract; bail (log) if it is gone - a stale AS row firing
+	 *     against a deleted contract is not worth throwing over (AS would retry
+	 *     a permanent failure forever).
+	 *  2. Skip non-active contracts (on-hold / pending-cancellation / terminal).
+	 *     The lifecycle path should have cleared the AS row, but a row can slip
+	 *     through (migration, manual SQL); skipping is the safe default. Skip
+	 *     gateway-scheduled contracts the same way - the gateway owns the charge.
+	 *  3. Idempotency guard: if a renewal order for this contract's *current*
+	 *     cycle already exists, do not create a second one. Tolerates AS
+	 *     retries without double-charging or double-advancing.
+	 *  4. Build the renewal order, attempt the gateway charge, advance the
+	 *     contract clock (cycle_count + next_payment_gmt, or terminal on
+	 *     max_cycles), persist, and re-schedule the next cycle.
+	 *
+	 * Returns the renewal order, or null when the renewal was skipped.
+	 *
+	 * @param int $contract_id Contract whose renewal cycle is firing.
+	 * @return WC_Order|null The created renewal order, or null when skipped.
+	 */
+	public function process_due( int $contract_id ): ?WC_Order {
+		$contract = $this->contracts->find( $contract_id );
+		if ( null === $contract ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine::process_due(): unknown contract %d - skipping (stale scheduled action).', $contract_id ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+				)
+			);
+			return null;
+		}
+
+		if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine::process_due(): contract %d is gateway-scheduled - skipping. The gateway owns the renewal; this primitive row should not have fired.', $contract_id ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+				)
+			);
+			return null;
+		}
+
+		if ( ContractStatus::ACTIVE !== $contract->get_status() ) {
+			wc_get_logger()->info(
+				sprintf( 'RenewalEngine::process_due(): contract %d is %s, not active - skipping renewal. No order created.', $contract_id, $contract->get_status() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+					'status'      => $contract->get_status(),
+				)
+			);
+			return null;
+		}
+
+		// Idempotency: a renewal order already tagged for this contract's
+		// current cycle means this action already ran (AS retry, double-fire).
+		// Bail without creating a second order or advancing again.
+		if ( $this->renewal_exists_for_cycle( $contract_id, $contract->get_cycle_count() + 1 ) ) {
+			wc_get_logger()->info(
+				sprintf( 'RenewalEngine::process_due(): renewal for contract %d cycle %d already exists - skipping (idempotent retry).', $contract_id, $contract->get_cycle_count() + 1 ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => $contract_id,
+				)
+			);
+			return null;
+		}
+
+		$renewal_order = $this->build_renewal_order( $contract );
+		if ( null === $renewal_order ) {
+			// build_renewal_order logged the reason; do not advance a contract
+			// whose renewal we could not create.
+			return null;
+		}
+
+		$this->advance_contract( $contract );
+		$this->contracts->update( $contract );
+
+		do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
+
+		$this->attempt_charge( $renewal_order, $contract );
+
+		// Re-arm the next cycle when the contract is still active (i.e. it did
+		// not just hit max_cycles).
+		if ( ContractStatus::ACTIVE === $contract->get_status() ) {
+			$this->schedule( $contract );
+		} else {
+			RenewalScheduler::unschedule( $contract_id );
+		}
+
+		return $renewal_order;
+	}
+
+	/**
+	 * Cancel `$contract`: transition to cancelled and clear its pending renewal.
+	 *
+	 * Status moves through the Core state machine ({@see Contract::set_status()}),
+	 * which raises a `DomainException` on an illegal transition (for example
+	 * cancelling an already-expired contract). On success the contract is
+	 * persisted and its AS row cleared.
+	 *
+	 * @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( 'RenewalEngine::cancel(): cannot cancel a contract that has no id.' );
+		}
+
+		$contract->set_status( ContractStatus::CANCELLED );
+		$this->contracts->update( $contract );
+
+		RenewalScheduler::unschedule( $id );
+
+		return true;
+	}
+
+	/**
+	 * Advance `$contract` one cycle: bump the paid-cycle count and either
+	 * compute the next bill date or end the contract on max_cycles.
+	 *
+	 * The decision is delegated to the pure Core {@see RenewalCalculator} with
+	 * an explicit `$now`. When the plan is missing (deleted out from under the
+	 * contract) the clock cannot advance; the cycle count still moves and the
+	 * next date is cleared, leaving a visible "active, no next date" state.
+	 *
+	 * @param Contract $contract Contract to advance in place.
+	 */
+	private function advance_contract( Contract $contract ): void {
+		$now       = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+		$new_count = $contract->get_cycle_count() + 1;
+		$plan      = $this->plans->find( $contract->get_selling_plan_id() );
+
+		$contract->set_cycle_count( $new_count );
+		$contract->set_last_payment_gmt( $now->format( 'Y-m-d H:i:s' ) );
+
+		if ( null === $plan ) {
+			wc_get_logger()->warning(
+				sprintf( 'RenewalEngine::advance_contract(): contract %d references a missing plan %d - cannot compute the next bill date. Cycle advanced; next_payment_gmt cleared.', (int) $contract->get_id(), $contract->get_selling_plan_id() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+				)
+			);
+			$contract->set_next_payment_gmt( null );
+			return;
+		}
+
+		$policy = $plan->get_billing_policy();
+
+		if ( RenewalCalculator::has_reached_max_cycles( $policy, $new_count ) ) {
+			$contract->set_next_payment_gmt( null );
+			$contract->set_status( ContractStatus::EXPIRED );
+			return;
+		}
+
+		// Anchor the next cycle on the cycle that just billed. Using the
+		// contract's prior next_payment_gmt as the anchor (not `now`) keeps the
+		// cadence on its calendar grid even if the job fired late.
+		$anchor_gmt = $contract->get_next_payment_gmt();
+		$anchor     = null !== $anchor_gmt
+			? new DateTimeImmutable( $anchor_gmt, new DateTimeZone( 'UTC' ) )
+			: $now;
+
+		$next = RenewalCalculator::next_bill_date( $policy, $anchor );
+		$contract->set_next_payment_gmt( $next->format( 'Y-m-d H:i:s' ) );
+	}
+
+	/**
+	 * Build a renewal order cloned from the contract's origin order.
+	 *
+	 * Clones line/fee/shipping/tax/coupon items and addresses from the origin,
+	 * applies the contract's stored recurring totals as ground truth, attaches
+	 * the contract's payment token, and tags the order with the renewal
+	 * relation meta (contract id + current cycle) 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 - the caller then skips the
+	 * advance.
+	 *
+	 * @param Contract $contract Contract being renewed.
+	 * @return WC_Order|null The saved pending renewal order, or null on failure.
+	 */
+	private function build_renewal_order( Contract $contract ): ?WC_Order {
+		$origin = wc_get_order( $contract->get_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(), $contract->get_origin_order_id() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+				)
+			);
+			return null;
+		}
+
+		$renewal_order = wc_create_order(
+			array(
+				'customer_id' => $contract->get_customer_id(),
+				'status'      => 'pending',
+				'created_via' => 'woocommerce_subscriptions_engine_renewal',
+			)
+		);
+
+		if ( is_wp_error( $renewal_order ) ) {
+			wc_get_logger()->error(
+				sprintf( 'RenewalEngine: wc_create_order() failed for contract %d: %s', (int) $contract->get_id(), $renewal_order->get_error_message() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+				)
+			);
+			return null;
+		}
+
+		$instrument = $contract->get_payment_instrument();
+
+		$renewal_order->set_currency( $contract->get_currency() );
+		if ( null !== $instrument->get_gateway() ) {
+			$renewal_order->set_payment_method( (string) $instrument->get_gateway() );
+		}
+		if ( null !== $instrument->get_title() ) {
+			$renewal_order->set_payment_method_title( (string) $instrument->get_title() );
+		}
+
+		$renewal_order->set_address( $origin->get_address( 'billing' ), 'billing' );
+		$renewal_order->set_address( $origin->get_address( 'shipping' ), 'shipping' );
+
+		// Clone every relevant line type. `set_id( 0 )` turns each clone into a
+		// fresh row attached to 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 );
+		}
+
+		// Contract totals are the price authority for the cycle - applied after
+		// add_item() so the line items do not recompute over them.
+		$renewal_order->set_discount_total( $contract->get_discount_total() );
+		$renewal_order->set_shipping_total( $contract->get_shipping_total() );
+		$renewal_order->set_cart_tax( $contract->get_tax_total() );
+		$renewal_order->set_total( $contract->get_billing_total() );
+
+		$token_id = $instrument->get_token_id();
+		if ( null !== $token_id ) {
+			$token = \WC_Payment_Tokens::get( $token_id );
+			if ( $token instanceof \WC_Payment_Token ) {
+				$renewal_order->add_payment_token( $token );
+			}
+		}
+
+		// Tag with the renewal relation + the cycle this order bills, so the
+		// idempotency check can detect a duplicate fire for the same cycle.
+		$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) ( $contract->get_cycle_count() + 1 ) );
+
+		$renewal_order->save();
+
+		return $renewal_order;
+	}
+
+	/**
+	 * Attempt the gateway charge for `$renewal_order`.
+	 *
+	 * Mirrors WooCommerce Subscriptions' scheduled-payment dispatch: fire
+	 * `woocommerce_subscriptions_engine_scheduled_payment_{gateway}` with the
+	 * amount and the order so the contract's gateway (or its adapter) captures
+	 * using the stored token. The engine does not implement gateway charging
+	 * itself - it hands off to whatever gateway integration is registered, and
+	 * the resulting WC payment-complete / failed status is what downstream
+	 * accounting observes. A gateway that registers no handler leaves the order
+	 * `pending` (uncharged), which is the correct safe state for a contract
+	 * scheduled against a gateway that cannot actually charge.
+	 *
+	 * @param WC_Order $renewal_order The pending renewal order to charge.
+	 * @param Contract $contract      The contract being renewed.
+	 */
+	private function attempt_charge( WC_Order $renewal_order, Contract $contract ): void {
+		$gateway_id = $contract->get_payment_instrument()->get_gateway();
+		if ( null === $gateway_id || '' === $gateway_id ) {
+			return;
+		}
+
+		$amount = (float) $renewal_order->get_total();
+
+		try {
+			/**
+			 * Fires to request a recurring charge for a renewal order.
+			 *
+			 * The contract's gateway (or a gateway adapter) hooks the
+			 * gateway-specific variant and captures against the stored token,
+			 * then transitions the order via the gateway's own
+			 * `payment_complete()` / failure handling.
+			 *
+			 * @param float    $amount        The amount to charge.
+			 * @param WC_Order $renewal_order The renewal order being charged.
+			 */
+			do_action( 'woocommerce_subscriptions_engine_scheduled_payment_' . $gateway_id, $amount, $renewal_order );
+		} catch ( Throwable $e ) {
+			// A throwing gateway handler must not leave the AS action in a
+			// retry-forever loop or roll back the advance we already persisted.
+			// Log and move on; the order stays pending for dunning to pick up.
+			wc_get_logger()->error(
+				sprintf( 'RenewalEngine: gateway charge for renewal order %d (contract %d) threw: %s', $renewal_order->get_id(), (int) $contract->get_id(), $e->getMessage() ),
+				array(
+					'source'      => self::LOG_SOURCE,
+					'contract_id' => (int) $contract->get_id(),
+					'order_id'    => $renewal_order->get_id(),
+				)
+			);
+		}
+	}
+
+	/**
+	 * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists.
+	 *
+	 * The idempotency check for AS retries. Queries on the single most-selective
+	 * key (the contract id) via the flat `meta_key` / `meta_value` shortcut, then
+	 * narrows by relation type and cycle in PHP. The flat shortcut is used rather
+	 * than a three-clause `meta_query` because the legacy CPT order store (the
+	 * fallback under HPOS, and the only store with HPOS off) rejects `meta_query`
+	 * with `wc_doing_it_wrong` and drops it; the flat shortcut round-trips
+	 * through both stores. A contract has a handful of renewal orders, so the
+	 * in-memory narrowing is cheap.
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $cycle       The cycle number the renewal would bill.
+	 */
+	private function renewal_exists_for_cycle( int $contract_id, int $cycle ): bool {
+		$orders = wc_get_orders(
+			array(
+				'limit'      => -1,
+				'status'     => 'any',
+				'type'       => 'shop_order',
+				'meta_key'   => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				'meta_value' => (string) $contract_id,          // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			)
+		);
+
+		foreach ( $orders as $order ) {
+			if ( ! $order instanceof WC_Order ) {
+				continue;
+			}
+
+			if ( OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
+				&& (string) $cycle === $order->get_meta( self::renewal_cycle_meta_key() ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Order meta key recording which cycle a renewal order bills.
+	 *
+	 * Used by the per-cycle idempotency check. Internal to the engine.
+	 */
+	private static function renewal_cycle_meta_key(): string {
+		return '_subscription_renewal_cycle';
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php
new file mode 100644
index 00000000000..068538acc0c
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalScheduler.php
@@ -0,0 +1,111 @@
+<?php
+/**
+ * RenewalScheduler - the Action Scheduler bridge for renewal dispatch.
+ *
+ * Owns the AS hook and group conventions and keeps them behind
+ * {@see RenewalEngine} so callers never couple to Action Scheduler hook names.
+ * If the scheduling substrate ever changes, this is the only file that has to.
+ *
+ * POC shape (one job per contract): each contract has at most one pending AS
+ * row, keyed by contract id. This is deliberately NOT the batch dispatcher the
+ * engine targets long-term (a few recurring jobs scanning a due index with
+ * lease claims) - that arrives with the cycles/attempts reshape. Until then,
+ * one-job-per-contract is the simplest thing that runs the money-path.
+ *
+ * Integration zone: WordPress-native. Calls Action Scheduler's `as_*()`
+ * functions directly.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Action Scheduler bridge for renewals.
+ */
+final class RenewalScheduler {
+
+	/**
+	 * Action Scheduler hook fired when a contract's renewal is due.
+	 *
+	 * Public so tooling and tests can inspect or cancel pending actions via
+	 * `as_has_scheduled_action()` and friends.
+	 */
+	const HOOK = 'woocommerce_subscriptions_engine_process_renewal';
+
+	/**
+	 * Action Scheduler group - used for admin filterability (Tools ->
+	 * Scheduled Actions) and bulk teardown.
+	 */
+	const GROUP = 'woocommerce_subscriptions_engine';
+
+	/**
+	 * Enqueue an AS action for `$contract_id` at `$when`.
+	 *
+	 * Does NOT clear pre-existing pending actions - {@see RenewalEngine::schedule()}
+	 * owns the clear-then-enqueue that keeps the single-row-per-contract
+	 * invariant. Calling this directly without first unscheduling will produce
+	 * duplicate rows.
+	 *
+	 * `$when->getTimestamp()` is a UTC unix timestamp regardless of the
+	 * argument's timezone, so the dispatch moment is unambiguous.
+	 *
+	 * @param int               $contract_id Contract whose renewal is being scheduled.
+	 * @param DateTimeImmutable $when        When the renewal should fire.
+	 */
+	public static function schedule( int $contract_id, DateTimeImmutable $when ): void {
+		as_schedule_single_action(
+			$when->getTimestamp(),
+			self::HOOK,
+			array( $contract_id ),
+			self::GROUP
+		);
+	}
+
+	/**
+	 * Cancel any pending AS action for `$contract_id`.
+	 *
+	 * AS matches on hook + args + group, so passing `[ $contract_id ]` scopes
+	 * the cancel to that one contract. No-op when nothing is pending.
+	 *
+	 * @param int $contract_id Contract whose pending renewal to clear.
+	 */
+	public static function unschedule( int $contract_id ): void {
+		as_unschedule_all_actions( self::HOOK, array( $contract_id ), self::GROUP );
+	}
+
+	/**
+	 * Whether a renewal action is currently pending for `$contract_id`.
+	 *
+	 * @param int $contract_id Contract to query.
+	 */
+	public static function is_scheduled( int $contract_id ): bool {
+		return false !== as_next_scheduled_action( self::HOOK, array( $contract_id ), self::GROUP );
+	}
+
+	/**
+	 * The moment the next pending renewal will fire for `$contract_id`, or null
+	 * if nothing is queued.
+	 *
+	 * AS returns an int unix timestamp (UTC) for a pending action, or
+	 * `false` / `0` when nothing future is queued.
+	 *
+	 * @param int $contract_id Contract to query.
+	 * @return DateTimeImmutable|null UTC moment of the next renewal, or null.
+	 */
+	public static function next_scheduled( int $contract_id ): ?DateTimeImmutable {
+		$timestamp = as_next_scheduled_action( self::HOOK, array( $contract_id ), self::GROUP );
+		if ( ! is_int( $timestamp ) || $timestamp <= 0 ) {
+			return null;
+		}
+
+		return ( new DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new DateTimeZone( 'UTC' ) );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index 05ff32966e4..8a6d9f6ba5e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -80,6 +80,86 @@ final class ContractRepository {
 		return $id;
 	}

+	/**
+	 * Persist changes to an existing contract and its child rows.
+	 *
+	 * Updates the contract row in place, then replaces the items, addresses,
+	 * and meta rows wholesale (delete-then-reinsert). Replacement rather than a
+	 * per-row diff keeps update() symmetric with insert() - the child tables
+	 * always reflect the entity's current arrays - at the cost of churning rows
+	 * that did not change. The renewal money-path mutates only contract-row
+	 * scheduling fields (next_payment_gmt, cycle_count, status, the *_gmt
+	 * stamps), so the child churn is rare in practice; revisit with a diffing
+	 * upsert if a high-frequency child-row writer appears.
+	 *
+	 * @param Contract $contract Contract to update. Must have an id whose row still exists.
+	 * @return bool True when the contract row was updated (or already current).
+	 * @throws \RuntimeException If the contract has no id, or its row no longer exists.
+	 */
+	public function update( Contract $contract ): bool {
+		global $wpdb;
+
+		$id = $contract->get_id();
+		if ( null === $id ) {
+			throw new \RuntimeException( 'Cannot update a contract that has no id. Use ContractRepository::insert() for a new contract.' );
+		}
+
+		$contracts_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+		// Guard against a stale id / concurrent delete before touching any table.
+		// `$wpdb->update()` returns 0 for both "matched, nothing changed" and "no
+		// row matched", so on its own it cannot tell that the parent is gone - and
+		// the child delete/reinsert below would then write orphan rows, since no
+		// foreign key enforces the relation. This narrows but does not fully close
+		// the race; the complete fix wraps the whole method in a transaction with
+		// SELECT ... FOR UPDATE, tracked as separate hardening (the integration
+		// suite's transaction-based isolation needs a test-safe approach first).
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$contracts_table} WHERE id = %d", $id ) );
+		if ( null === $exists ) {
+			throw new \RuntimeException(
+				esc_html( sprintf( 'Cannot update contract %d: the contract row no longer exists (stale id or concurrent delete).', $id ) )
+			);
+		}
+
+		$data = $contract->to_storage();
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+		$updated = $wpdb->update(
+			$contracts_table,
+			array_merge(
+				$data,
+				array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
+			),
+			array( 'id' => $id )
+		);
+
+		if ( false === $updated ) {
+			throw new \RuntimeException( 'Failed to update contract.' );
+		}
+
+		// Replace child rows so they mirror the entity's current arrays. Same
+		// delete set as delete(), minus the contract row itself.
+		foreach ( array(
+			SchemaInstaller::TABLE_CONTRACT_ITEMS,
+			SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
+			SchemaInstaller::TABLE_CONTRACT_META,
+		) as $child ) {
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+			$wpdb->delete( SchemaInstaller::get_table_name( $child ), array( 'contract_id' => $id ) );
+		}
+
+		$this->insert_items( $id, $contract->get_items() );
+		$this->insert_addresses( $id, $contract->get_addresses() );
+		$this->insert_meta( $id, $contract->get_meta() );
+
+		// `$wpdb->update` returns 0 (int) when the row matched but no column
+		// changed - a successful no-op, not a failure. Only `false` is an error,
+		// and that path threw above; the "row no longer exists" case is ruled out
+		// by the existence guard at the top of this method.
+		return true;
+	}
+
 	/**
 	 * Fetch a contract by id, including its items, addresses, and meta.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
new file mode 100644
index 00000000000..451ba7b56f3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
@@ -0,0 +1,167 @@
+<?php
+/**
+ * Integration tests for ContractFactory.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Checkout;
+
+use EngineIntegrationTestCase;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
+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\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage
+ */
+class ContractFactoryTest extends EngineIntegrationTestCase {
+
+	private function make_plan( ?int $max_cycles = null, ?array $trial = null ): Plan {
+		$group_id = ( new PlanGroupRepository() )->insert(
+			PlanGroup::create( array( 'name' => 'Coffee club' ) )
+		);
+
+		$plan = Plan::create(
+			$group_id,
+			array(
+				'name'           => 'Monthly coffee',
+				'billing_policy' => new BillingPolicy( 'month', 1, null, $max_cycles, $trial ),
+				'category'       => Plan::DEFAULT_CATEGORY,
+				'extension_slug' => 'lite',
+			)
+		);
+		( new PlanRepository() )->insert( $plan );
+
+		return $plan;
+	}
+
+	private function make_order(): WC_Order {
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( 'woocommerce_payments' );
+		$order->set_payment_method_title( 'Credit card' );
+		$order->set_total( '19.99' );
+		$order->set_address(
+			array(
+				'first_name' => 'Ada',
+				'last_name'  => 'Lovelace',
+				'country'    => 'US',
+				'email'      => 'ada@example.test',
+			),
+			'billing'
+		);
+		$order->save();
+
+		return $order;
+	}
+
+	public function test_create_from_order_persists_and_links_contract(): void {
+		$order = $this->make_order();
+		$plan  = $this->make_plan();
+
+		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+
+		$this->assertInstanceOf( Contract::class, $contract );
+		$this->assertNotNull( $contract->get_id() );
+		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+		$this->assertSame( 'USD', $contract->get_currency() );
+		$this->assertSame( $plan->get_id(), $contract->get_selling_plan_id() );
+		$this->assertSame( $order->get_id(), $contract->get_origin_order_id() );
+		$this->assertSame( 'lite', $contract->get_extension_slug() );
+		$this->assertSame( 'woocommerce_payments', $contract->get_payment_instrument()->get_gateway() );
+
+		// Persisted and reloadable.
+		$reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+		$this->assertInstanceOf( Contract::class, $reloaded );
+		$this->assertSame( $contract->get_id(), $reloaded->get_id() );
+
+		// Order is tagged with the parent relation.
+		$tagged_order = wc_get_order( $order->get_id() );
+		$this->assertSame( (string) $contract->get_id(), $tagged_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
+		$this->assertSame( OrderLinkage::RELATION_PARENT, $tagged_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
+	}
+
+	public function test_first_renewal_date_follows_billing_cadence(): void {
+		$order = $this->make_order();
+		$order->set_date_paid( '2026-01-15 00:00:00' );
+		$order->save();
+
+		$contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
+
+		// Monthly cadence anchored on the paid date.
+		$this->assertSame( '2026-02-15 00:00:00', $contract->get_next_payment_gmt() );
+	}
+
+	public function test_native_trial_delays_first_renewal(): void {
+		$order = $this->make_order();
+		$order->set_date_paid( '2026-01-15 00:00:00' );
+		$order->save();
+
+		$plan = $this->make_plan(
+			null,
+			array(
+				'length' => 14,
+				'unit'   => 'day',
+			)
+		);
+
+		$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+
+		// First bill is the trial end, not one month out.
+		$this->assertSame( '2026-01-29 00:00:00', $contract->get_next_payment_gmt() );
+	}
+
+	public function test_overrides_take_precedence(): void {
+		$order = $this->make_order();
+		$plan  = $this->make_plan();
+
+		$contract = ( new ContractFactory() )->create_from_order(
+			$order,
+			$plan,
+			array(
+				'billing_total'    => '49.00',
+				'next_payment_gmt' => '2026-12-01 00:00:00',
+			)
+		);
+
+		$this->assertSame( '49.00', $contract->get_billing_total() );
+		$this->assertSame( '2026-12-01 00:00:00', $contract->get_next_payment_gmt() );
+	}
+
+	public function test_unsaved_plan_is_rejected(): void {
+		$order = $this->make_order();
+		$plan  = Plan::create(
+			1,
+			array(
+				'name'           => 'Monthly coffee',
+				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+				'category'       => Plan::DEFAULT_CATEGORY,
+			)
+		);
+
+		$this->expectException( \RuntimeException::class );
+		( new ContractFactory() )->create_from_order( $order, $plan );
+	}
+
+	public function test_unsaved_order_is_rejected(): void {
+		// An order that was never saved reports id 0, which would persist
+		// origin_order_id => 0 and link the contract to a non-existent order.
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+
+		$this->expectException( \RuntimeException::class );
+		( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
+	}
+}
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
new file mode 100644
index 00000000000..5f441c518d7
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * Integration tests for RenewalEngine (and RenewalScheduler).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Renewal;
+
+use EngineIntegrationTestCase;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\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\OrderLinkage;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanGroupRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalEngine
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalScheduler
+ */
+class RenewalEngineTest extends EngineIntegrationTestCase {
+
+	private const GATEWAY = 'engine_test_gateway';
+
+	public function set_up(): void {
+		parent::set_up();
+		GatewayCapabilities::reset();
+	}
+
+	public function tear_down(): void {
+		GatewayCapabilities::reset();
+		parent::tear_down();
+	}
+
+	private function make_plan( ?int $max_cycles = null ): int {
+		$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, $max_cycles, null ),
+				'category'       => Plan::DEFAULT_CATEGORY,
+			)
+		);
+		( new PlanRepository() )->insert( $plan );
+
+		return (int) $plan->get_id();
+	}
+
+	private function make_origin_order(): WC_Order {
+		$order = new WC_Order();
+		$order->set_currency( 'USD' );
+		$order->set_payment_method( self::GATEWAY );
+		$order->set_total( '19.99' );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Renewal orders tagged for a contract at a given cycle, narrowed in PHP
+	 * (store-agnostic, like the engine's own idempotency check).
+	 *
+	 * @param int $contract_id Contract id.
+	 * @param int $cycle       Cycle number.
+	 * @return array<int, WC_Order>
+	 */
+	private function renewal_orders_for_cycle( int $contract_id, int $cycle ): array {
+		$orders = wc_get_orders(
+			array(
+				'limit'      => -1,
+				'type'       => 'shop_order',
+				'status'     => 'any',
+				'meta_key'   => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				'meta_value' => (string) $contract_id,          // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			)
+		);
+
+		return array_values(
+			array_filter(
+				$orders,
+				static function ( $order ) use ( $cycle ) {
+					return $order instanceof WC_Order
+						&& OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
+						&& (string) $cycle === $order->get_meta( '_subscription_renewal_cycle' );
+				}
+			)
+		);
+	}
+
+	private function make_contract( int $plan_id, int $origin_order_id, ?int $max_cycles = null ): Contract {
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'currency'         => 'USD',
+				'selling_plan_id'  => $plan_id,
+				'origin_order_id'  => $origin_order_id,
+				'payment_method'   => self::GATEWAY,
+				'billing_total'    => '19.99',
+				'start_gmt'        => '2026-01-15 00:00:00',
+				'next_payment_gmt' => '2026-02-15 00:00:00',
+			)
+		);
+		( new ContractRepository() )->insert( $contract );
+
+		return $contract;
+	}
+
+	public function test_schedule_is_gated_on_recurring_capability(): void {
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+
+		$engine = new RenewalEngine();
+
+		// No capability declared: scheduling is refused.
+		$this->assertFalse( $engine->schedule( $contract ) );
+		$this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+
+		// Declare it and the schedule sticks.
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+		$this->assertTrue( $engine->schedule( $contract ) );
+		$this->assertTrue( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+	}
+
+	public function test_schedule_replaces_existing_row(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+
+		$engine = new RenewalEngine();
+		$engine->schedule( $contract );
+		$engine->schedule( $contract );
+
+		// Exactly one pending row for the contract.
+		$pending = as_get_scheduled_actions(
+			array(
+				'hook'   => RenewalScheduler::HOOK,
+				'args'   => array( $contract->get_id() ),
+				'status' => \ActionScheduler_Store::STATUS_PENDING,
+			),
+			'ids'
+		);
+		$this->assertCount( 1, $pending );
+	}
+
+	public function test_process_due_creates_renewal_and_advances(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+
+		$engine        = new RenewalEngine();
+		$renewal_order = $engine->process_due( $contract->get_id() );
+
+		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$this->assertSame( (string) $contract->get_id(), $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
+		$this->assertSame( OrderLinkage::RELATION_RENEWAL, $renewal_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
+		$this->assertSame( '19.99', $renewal_order->get_total() );
+
+		// Contract advanced: cycle_count incremented, next bill date moved one month.
+		$reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+		$this->assertSame( 1, $reloaded->get_cycle_count() );
+		$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
+		$this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
+
+		// Next cycle re-armed.
+		$this->assertTrue( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+	}
+
+	public function test_process_due_is_idempotent_for_a_retried_cycle(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$repo     = new ContractRepository();
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+		$engine   = new RenewalEngine();
+
+		// First fire creates the cycle-1 renewal and advances to cycle 1.
+		$first = $engine->process_due( $contract->get_id() );
+		$this->assertInstanceOf( WC_Order::class, $first );
+
+		// Simulate an Action Scheduler retry of the same due action: rewind the
+		// persisted contract to its pre-advance state (cycle 0, original next
+		// date) so the retry attempts cycle 1 again - exactly what a duplicate
+		// dispatch would do before the advance committed.
+		$rewound = $repo->find( $contract->get_id() );
+		$rewound->set_cycle_count( 0 );
+		$rewound->set_status( ContractStatus::ACTIVE );
+		$rewound->set_next_payment_gmt( '2026-02-15 00:00:00' );
+		$repo->update( $rewound );
+
+		$retry = $engine->process_due( $contract->get_id() );
+
+		// The per-cycle guard suppresses the retry: no second order, no advance.
+		$this->assertNull( $retry );
+
+		$this->assertCount( 1, $this->renewal_orders_for_cycle( $contract->get_id(), 1 ) );
+
+		$reloaded = $repo->find( $contract->get_id() );
+		$this->assertSame( 0, $reloaded->get_cycle_count() );
+	}
+
+	public function test_process_due_expires_contract_at_max_cycles(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id  = $this->make_plan( 1 );
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+
+		$engine = new RenewalEngine();
+		$engine->process_due( $contract->get_id() );
+
+		$reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+		$this->assertSame( 1, $reloaded->get_cycle_count() );
+		$this->assertSame( ContractStatus::EXPIRED, $reloaded->get_status() );
+		$this->assertNull( $reloaded->get_next_payment_gmt() );
+		$this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+	}
+
+	public function test_process_due_skips_non_active_contract(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+		$contract->set_status( ContractStatus::ON_HOLD );
+		( new ContractRepository() )->update( $contract );
+
+		$this->assertNull( ( new RenewalEngine() )->process_due( $contract->get_id() ) );
+
+		$reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+		$this->assertSame( 0, $reloaded->get_cycle_count() );
+	}
+
+	public function test_process_due_skips_unknown_contract(): void {
+		$this->assertNull( ( new RenewalEngine() )->process_due( 999999 ) );
+	}
+
+	public function test_cancel_transitions_and_unschedules(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = $this->make_contract( $plan_id, $order->get_id() );
+
+		$engine = new RenewalEngine();
+		$engine->schedule( $contract );
+		$this->assertTrue( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+
+		$this->assertTrue( $engine->cancel( $contract ) );
+
+		$reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+		$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
+		$this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+	}
+
+	public function test_gateway_scheduled_contract_is_not_scheduled(): void {
+		GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+
+		$plan_id  = $this->make_plan();
+		$order    = $this->make_origin_order();
+		$contract = Contract::create(
+			array(
+				'customer_id'      => 1,
+				'currency'         => 'USD',
+				'selling_plan_id'  => $plan_id,
+				'origin_order_id'  => $order->get_id(),
+				'payment_method'   => self::GATEWAY,
+				'billing_total'    => '19.99',
+				'start_gmt'        => '2026-01-15 00:00:00',
+				'next_payment_gmt' => '2026-02-15 00:00:00',
+				'schedule_source'  => Contract::SCHEDULE_SOURCE_GATEWAY,
+			)
+		);
+		( new ContractRepository() )->insert( $contract );
+
+		$this->assertFalse( ( new RenewalEngine() )->schedule( $contract ) );
+		$this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+	}
+}
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 31aecc79a24..c6b784ba86c 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
@@ -117,6 +117,99 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertNull( $repo->find( $id )->get_extension_slug() );
 	}

+	public function test_update_persists_scheduling_fields(): void {
+		$repo = new ContractRepository();
+		$id   = $repo->insert( $this->make_contract() );
+
+		$contract = $repo->find( $id );
+		$contract->set_status( ContractStatus::ON_HOLD );
+		$contract->set_next_payment_gmt( '2026-08-15 00:00:00' );
+		$contract->set_cycle_count( 3 );
+		$contract->set_last_payment_gmt( '2026-07-15 00:00:00' );
+
+		$this->assertTrue( $repo->update( $contract ) );
+
+		$reloaded = $repo->find( $id );
+		$this->assertSame( ContractStatus::ON_HOLD, $reloaded->get_status() );
+		$this->assertSame( '2026-08-15 00:00:00', $reloaded->get_next_payment_gmt() );
+		$this->assertSame( 3, $reloaded->get_cycle_count() );
+		$this->assertSame( '2026-07-15 00:00:00', $reloaded->get_last_payment_gmt() );
+	}
+
+	public function test_update_replaces_child_rows(): void {
+		$repo = new ContractRepository();
+		$id   = $repo->insert( $this->make_contract() );
+
+		$contract = $repo->find( $id );
+		$this->assertCount( 1, $contract->get_items() );
+
+		// Re-create with a different set of items / meta and update.
+		$mutated = Contract::create(
+			array(
+				'customer_id'     => $contract->get_customer_id(),
+				'currency'        => $contract->get_currency(),
+				'selling_plan_id' => $contract->get_selling_plan_id(),
+				'origin_order_id' => $contract->get_origin_order_id(),
+				'start_gmt'       => $contract->get_start_gmt(),
+				'billing_total'   => $contract->get_billing_total(),
+				'items'           => array(
+					array(
+						'item_name'  => 'Tea tin',
+						'item_type'  => 'line_item',
+						'product_id' => 300,
+						'quantity'   => '2',
+						'subtotal'   => '24.00',
+						'total'      => '24.00',
+					),
+				),
+				'meta'            => array( 'source_channel' => 'email' ),
+			)
+		);
+		$mutated->set_id( $id );
+
+		$this->assertTrue( $repo->update( $mutated ) );
+
+		$reloaded = $repo->find( $id );
+		$items    = $reloaded->get_items();
+		$this->assertCount( 1, $items );
+		$this->assertSame( 'Tea tin', $items[0]['item_name'] );
+		$this->assertSame( 'email', $reloaded->get_meta()['source_channel'] );
+	}
+
+	public function test_update_throws_without_id(): void {
+		$this->expectException( \RuntimeException::class );
+		( new ContractRepository() )->update( $this->make_contract() );
+	}
+
+	public function test_update_rejects_deleted_contract_and_writes_no_orphans(): void {
+		global $wpdb;
+
+		$repo = new ContractRepository();
+		$id   = $repo->insert( $this->make_contract() );
+
+		// Simulate a concurrent delete: the contract row and its children are gone.
+		$this->assertTrue( $repo->delete( $id ) );
+
+		$stale = $this->make_contract();
+		$stale->set_id( $id );
+
+		try {
+			$repo->update( $stale );
+			$this->fail( 'Expected RuntimeException when updating a contract whose row no longer exists.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'no longer exists', $e->getMessage() );
+		}
+
+		// The guard fired before any child write, so no orphan rows were created.
+		$items_table = \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::get_table_name(
+			\Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::TABLE_CONTRACT_ITEMS
+		);
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$items_table} WHERE contract_id = %d", $id ) );
+
+		$this->assertSame( '0', $remaining );
+	}
+
 	public function test_delete_removes_contract_and_children(): void {
 		global $wpdb;

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php
index a51ac50ab03..0d52cf8d0dd 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php
@@ -2,9 +2,10 @@
 /**
  * Integration-test bootstrap for the WooCommerce Subscriptions Engine.
  *
- * Loads the WordPress test framework, the engine plugin file, and installs the
- * baseline schema once up front so per-test transaction rollback (provided by
- * WP_UnitTestCase) keeps each test isolated without re-running DDL.
+ * Loads the WordPress test framework, boots the engine package as a library,
+ * and installs the baseline schema once up front so per-test transaction
+ * rollback (provided by WP_UnitTestCase) keeps each test isolated without
+ * re-running DDL.
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -12,6 +13,7 @@
 declare( strict_types=1 );

 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Package;

 // phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- Bootstrap file mixes a class and procedural setup.

@@ -59,7 +61,7 @@ class SubscriptionsEngineTestsBootstrap {

 		require_once $this->wp_tests_dir . '/includes/functions.php';

-		tests_add_filter( 'muplugins_loaded', array( $this, 'load_plugin' ) );
+		tests_add_filter( 'muplugins_loaded', array( $this, 'load_dependencies' ) );

 		if ( ! defined( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH' ) ) {
 			define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', __DIR__ . '/../../vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php' );
@@ -67,6 +69,12 @@ class SubscriptionsEngineTestsBootstrap {

 		require_once $this->wp_tests_dir . '/includes/bootstrap.php';

+		// Install WooCommerce so its tables and runtime (order classes, logger)
+		// are available to the engine's integration layer.
+		if ( class_exists( \WC_Install::class ) ) {
+			\WC_Install::install();
+		}
+
 		// Install once, outside any test transaction, so the init-hook installer
 		// short-circuits during tests and DDL never breaks rollback isolation.
 		SchemaInstaller::install();
@@ -75,10 +83,22 @@ class SubscriptionsEngineTestsBootstrap {
 	}

 	/**
-	 * Load the engine plugin file.
+	 * Load WooCommerce, then boot the engine as a library.
 	 */
-	public function load_plugin(): void {
-		require_once $this->plugin_dir . '/woocommerce-subscriptions-engine.php';
+	public function load_dependencies(): void {
+		// WooCommerce first: the engine's integration layer depends on WC (orders,
+		// gateways, logger). Guarded so the bootstrap does not fatal in an
+		// environment that has not mounted WooCommerce.
+		$woocommerce = WP_PLUGIN_DIR . '/woocommerce/woocommerce.php';
+		if ( file_exists( $woocommerce ) ) {
+			require_once $woocommerce;
+		}
+
+		// The engine is a library, not a standalone plugin: load it through its
+		// Composer autoloader and boot it the way real consumers do, instead of
+		// activating it as a WordPress plugin.
+		require_once $this->plugin_dir . '/vendor/autoload.php';
+		Package::init();
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
index 3386892c05b..f7eaa2bc833 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
@@ -9,6 +9,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;

+use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;

@@ -23,8 +24,21 @@ class ContractStatusTest extends TestCase {
 	}

 	public function test_active_can_move_to_hold_and_back(): void {
-		$this->assertTrue( ContractStatus::can_transition( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
-		$this->assertTrue( ContractStatus::can_transition( ContractStatus::ON_HOLD, ContractStatus::ACTIVE ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::ACTIVE ) );
+	}
+
+	public function test_active_reaches_every_other_status(): void {
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::PENDING_CANCELLATION ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::CANCELLED ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::EXPIRED ) );
+	}
+
+	public function test_on_hold_cannot_expire(): void {
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::CANCELLED ) );
+		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ON_HOLD, ContractStatus::EXPIRED ) );
 	}

 	public function test_cancelled_and_expired_are_terminal(): void {
@@ -32,19 +46,56 @@ class ContractStatusTest extends TestCase {
 		$this->assertTrue( ContractStatus::is_terminal( ContractStatus::EXPIRED ) );

 		foreach ( ContractStatus::all() as $target ) {
-			$this->assertFalse( ContractStatus::can_transition( ContractStatus::CANCELLED, $target ) );
-			$this->assertFalse( ContractStatus::can_transition( ContractStatus::EXPIRED, $target ) );
+			$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::CANCELLED, $target ) );
+			$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::EXPIRED, $target ) );
 		}
 	}

 	public function test_pending_cancellation_only_reaches_active_or_cancelled(): void {
-		$this->assertTrue( ContractStatus::can_transition( ContractStatus::PENDING_CANCELLATION, ContractStatus::ACTIVE ) );
-		$this->assertTrue( ContractStatus::can_transition( ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED ) );
-		$this->assertFalse( ContractStatus::can_transition( ContractStatus::PENDING_CANCELLATION, ContractStatus::ON_HOLD ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::ACTIVE ) );
+		$this->assertTrue( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED ) );
+		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::ON_HOLD ) );
+		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::PENDING_CANCELLATION, ContractStatus::EXPIRED ) );
 	}

 	public function test_unknown_statuses_never_transition(): void {
-		$this->assertFalse( ContractStatus::can_transition( 'nonsense', ContractStatus::ACTIVE ) );
-		$this->assertFalse( ContractStatus::can_transition( ContractStatus::ACTIVE, 'nonsense' ) );
+		$this->assertFalse( ContractStatus::is_transition_allowed( 'nonsense', ContractStatus::ACTIVE ) );
+		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, 'nonsense' ) );
+	}
+
+	public function test_same_status_is_not_an_allowed_transition(): void {
+		// set_status() short-circuits no-ops; the table itself reports a
+		// same-status move as not allowed.
+		$this->assertFalse( ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ACTIVE ) );
+	}
+
+	public function test_can_transition_aliases_is_transition_allowed(): void {
+		$this->assertSame(
+			ContractStatus::is_transition_allowed( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ),
+			ContractStatus::can_transition( ContractStatus::ACTIVE, ContractStatus::ON_HOLD )
+		);
+		$this->assertSame(
+			ContractStatus::is_transition_allowed( ContractStatus::CANCELLED, ContractStatus::ACTIVE ),
+			ContractStatus::can_transition( ContractStatus::CANCELLED, ContractStatus::ACTIVE )
+		);
+	}
+
+	public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
+		ContractStatus::assert_transition_allowed( ContractStatus::ACTIVE, ContractStatus::CANCELLED );
+
+		// No exception thrown.
+		$this->addToAssertionCount( 1 );
+	}
+
+	public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
+		$this->expectException( DomainException::class );
+
+		ContractStatus::assert_transition_allowed( ContractStatus::CANCELLED, ContractStatus::ACTIVE );
+	}
+
+	public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
+		$this->expectException( DomainException::class );
+
+		ContractStatus::assert_transition_allowed( ContractStatus::EXPIRED, ContractStatus::ON_HOLD );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php
new file mode 100644
index 00000000000..8c8cbd3261d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTransitionTest.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Unit tests for status-transition enforcement on the Contract entity.
+ *
+ * Confirms that Contract::set_status() routes every status change through the
+ * ContractStatus state machine, so illegal moves are rejected at the entity
+ * boundary rather than being persisted.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract::set_status
+ */
+class ContractStatusTransitionTest extends TestCase {
+
+	/**
+	 * Build a minimal active contract for transition tests.
+	 *
+	 * @param string $status Starting status.
+	 */
+	private function make_contract( string $status = ContractStatus::ACTIVE ): Contract {
+		return Contract::create(
+			array(
+				'customer_id'     => 1,
+				'currency'        => 'USD',
+				'selling_plan_id' => 1,
+				'origin_order_id' => 1,
+				'start_gmt'       => '2026-01-01 00:00:00',
+				'status'          => $status,
+				'billing_total'   => '10.00',
+				'schedule_source' => Contract::SCHEDULE_SOURCE_PRIMITIVE,
+				'items'           => array(),
+				'addresses'       => array(),
+				'meta'            => array(),
+			)
+		);
+	}
+
+	public function test_legal_transition_is_applied(): void {
+		$contract = $this->make_contract( ContractStatus::ACTIVE );
+
+		$contract->set_status( ContractStatus::ON_HOLD );
+
+		$this->assertSame( ContractStatus::ON_HOLD, $contract->get_status() );
+	}
+
+	public function test_same_status_is_a_noop(): void {
+		$contract = $this->make_contract( ContractStatus::ACTIVE );
+
+		// Active is terminal-free but cannot transition to itself in the table;
+		// set_status() must treat the no-op as a no-op, not an exception.
+		$contract->set_status( ContractStatus::ACTIVE );
+
+		$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+	}
+
+	public function test_illegal_transition_throws_and_leaves_status_unchanged(): void {
+		$contract = $this->make_contract( ContractStatus::CANCELLED );
+
+		try {
+			$contract->set_status( ContractStatus::ACTIVE );
+			$this->fail( 'Expected a DomainException for a transition out of a terminal status.' );
+		} catch ( DomainException $e ) {
+			$this->assertSame( ContractStatus::CANCELLED, $contract->get_status() );
+		}
+	}
+
+	public function test_pending_cancellation_to_on_hold_is_rejected(): void {
+		$contract = $this->make_contract( ContractStatus::PENDING_CANCELLATION );
+
+		$this->expectException( DomainException::class );
+
+		$contract->set_status( ContractStatus::ON_HOLD );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
new file mode 100644
index 00000000000..e2b559f8c79
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Unit tests for Contract hydration invariants.
+ *
+ * Confirms that Contract::from_storage() enforces the same boundary invariants
+ * as the entity's setters, so a corrupted or migrated row cannot smuggle an
+ * illegal value past hydration.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract::from_storage
+ */
+class ContractTest extends TestCase {
+
+	/**
+	 * A complete, valid contract row.
+	 *
+	 * @param int $cycle_count Value for the cycle_count column.
+	 * @return array<string, mixed>
+	 */
+	private function valid_row( int $cycle_count ): array {
+		return array(
+			'id'               => 10,
+			'status'           => ContractStatus::ACTIVE,
+			'customer_id'      => 1,
+			'currency'         => 'USD',
+			'selling_plan_id'  => 2,
+			'origin_order_id'  => 3,
+			'billing_total'    => '10.00',
+			'start_gmt'        => '2026-01-01 00:00:00',
+			'next_payment_gmt' => '2026-02-01 00:00:00',
+			'cycle_count'      => $cycle_count,
+		);
+	}
+
+	public function test_from_storage_hydrates_cycle_count(): void {
+		$contract = Contract::from_storage( $this->valid_row( 3 ) );
+
+		$this->assertSame( 3, $contract->get_cycle_count() );
+	}
+
+	public function test_from_storage_rejects_negative_cycle_count(): void {
+		$this->expectException( DomainException::class );
+
+		Contract::from_storage( $this->valid_row( -1 ) );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
new file mode 100644
index 00000000000..9c2800f93d0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * Unit tests for the pure GatewayCapabilities declaration store.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Gateway;
+
+use InvalidArgumentException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities
+ */
+class GatewayCapabilitiesTest extends TestCase {
+
+	protected function setUp(): void {
+		parent::setUp();
+		GatewayCapabilities::reset();
+	}
+
+	protected function tearDown(): void {
+		GatewayCapabilities::reset();
+		parent::tearDown();
+	}
+
+	public function test_known_capabilities_lists_every_flag(): void {
+		$this->assertSame(
+			array(
+				'recurring',
+				'payment_method_change',
+				'amount_changes',
+				'multiple_per_customer',
+				'gateway_scheduled_renewals',
+			),
+			GatewayCapabilities::known_capabilities()
+		);
+	}
+
+	public function test_constants_match_their_string_values(): void {
+		$this->assertSame( 'recurring', GatewayCapabilities::RECURRING );
+		$this->assertSame( 'payment_method_change', GatewayCapabilities::PAYMENT_METHOD_CHANGE );
+		$this->assertSame( 'amount_changes', GatewayCapabilities::AMOUNT_CHANGES );
+		$this->assertSame( 'multiple_per_customer', GatewayCapabilities::MULTIPLE_PER_CUSTOMER );
+		$this->assertSame( 'gateway_scheduled_renewals', GatewayCapabilities::GATEWAY_SCHEDULED_RENEWALS );
+	}
+
+	public function test_declared_capabilities_resolve_true(): void {
+		GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
+
+		$this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_undeclared_capability_resolves_false(): void {
+		GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
+
+		$this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
+	}
+
+	public function test_unknown_gateway_resolves_false(): void {
+		$this->assertFalse( GatewayCapabilities::is_declared( 'never-declared', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_declare_with_unknown_flag_throws(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		GatewayCapabilities::declare( 'dummy', array( 'totally_made_up' ) );
+	}
+
+	public function test_declare_rejects_the_whole_batch_when_one_flag_is_unknown(): void {
+		try {
+			GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING, 'bogus' ) );
+			$this->fail( 'Expected an InvalidArgumentException for the unknown flag.' );
+		} catch ( InvalidArgumentException $e ) {
+			// The valid flag in the same batch must not have been stored.
+			$this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+		}
+	}
+
+	public function test_redeclaration_replaces_rather_than_merges(): void {
+		GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING, GatewayCapabilities::AMOUNT_CHANGES ) );
+		GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::PAYMENT_METHOD_CHANGE ) );
+
+		$this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::PAYMENT_METHOD_CHANGE ) );
+		$this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+		$this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
+	}
+
+	public function test_duplicate_flags_are_deduplicated(): void {
+		GatewayCapabilities::declare(
+			'dummy',
+			array(
+				GatewayCapabilities::RECURRING,
+				GatewayCapabilities::RECURRING,
+				GatewayCapabilities::AMOUNT_CHANGES,
+			)
+		);
+
+		// Behaviourally a duplicate must not change the answer; declaring twice
+		// is still a single positive declaration.
+		$this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+		$this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
+	}
+
+	public function test_empty_declaration_clears_a_gateway(): void {
+		GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
+		GatewayCapabilities::declare( 'dummy', array() );
+
+		$this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_declarations_are_isolated_per_gateway(): void {
+		GatewayCapabilities::declare( 'a', array( GatewayCapabilities::RECURRING ) );
+		GatewayCapabilities::declare( 'b', array( GatewayCapabilities::AMOUNT_CHANGES ) );
+
+		$this->assertTrue( GatewayCapabilities::is_declared( 'a', GatewayCapabilities::RECURRING ) );
+		$this->assertFalse( GatewayCapabilities::is_declared( 'a', GatewayCapabilities::AMOUNT_CHANGES ) );
+		$this->assertTrue( GatewayCapabilities::is_declared( 'b', GatewayCapabilities::AMOUNT_CHANGES ) );
+		$this->assertFalse( GatewayCapabilities::is_declared( 'b', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_reset_clears_all_declarations(): void {
+		GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
+		GatewayCapabilities::reset();
+
+		$this->assertFalse( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
new file mode 100644
index 00000000000..40c14f6ee96
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Renewal/RenewalCalculatorTest.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Unit tests for RenewalCalculator.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Renewal;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator
+ */
+class RenewalCalculatorTest extends TestCase {
+
+	private function policy( ?int $max_cycles, string $period = 'month', int $interval = 1 ): BillingPolicy {
+		return new BillingPolicy( $period, $interval, null, $max_cycles, null );
+	}
+
+	public function test_open_ended_policy_never_reaches_max_cycles(): void {
+		$policy = $this->policy( null );
+
+		$this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 0 ) );
+		$this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 99 ) );
+	}
+
+	public function test_not_terminal_below_max_cycles(): void {
+		$policy = $this->policy( 3 );
+
+		$this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 0 ) );
+		$this->assertFalse( RenewalCalculator::has_reached_max_cycles( $policy, 2 ) );
+	}
+
+	public function test_terminal_at_max_cycles(): void {
+		$policy = $this->policy( 3 );
+
+		$this->assertTrue( RenewalCalculator::has_reached_max_cycles( $policy, 3 ) );
+	}
+
+	public function test_terminal_when_over_counted(): void {
+		$policy = $this->policy( 3 );
+
+		$this->assertTrue( RenewalCalculator::has_reached_max_cycles( $policy, 4 ) );
+	}
+
+	public function test_next_bill_date_adds_one_cadence_in_utc(): void {
+		$policy = $this->policy( null, 'month', 1 );
+		$anchor = new DateTimeImmutable( '2026-01-15 10:00:00', new DateTimeZone( 'UTC' ) );
+
+		$next = RenewalCalculator::next_bill_date( $policy, $anchor );
+
+		$this->assertSame( '2026-02-15 10:00:00', $next->format( 'Y-m-d H:i:s' ) );
+		$this->assertSame( 'UTC', $next->getTimezone()->getName() );
+	}
+
+	public function test_next_bill_date_honours_interval(): void {
+		$policy = $this->policy( null, 'week', 2 );
+		$anchor = new DateTimeImmutable( '2026-03-01 00:00:00', new DateTimeZone( 'UTC' ) );
+
+		$next = RenewalCalculator::next_bill_date( $policy, $anchor );
+
+		$this->assertSame( '2026-03-15 00:00:00', $next->format( 'Y-m-d H:i:s' ) );
+	}
+
+	public function test_next_bill_date_normalizes_non_utc_anchor_to_utc(): void {
+		$policy = $this->policy( null, 'day', 1 );
+		// 2026-01-15 23:30 in a +05:00 zone is 2026-01-15 18:30 UTC; adding a
+		// day lands on 2026-01-16 18:30 UTC.
+		$anchor = new DateTimeImmutable( '2026-01-15 23:30:00', new DateTimeZone( '+05:00' ) );
+
+		$next = RenewalCalculator::next_bill_date( $policy, $anchor );
+
+		$this->assertSame( '2026-01-16 18:30:00', $next->format( 'Y-m-d H:i:s' ) );
+		$this->assertSame( 'UTC', $next->getTimezone()->getName() );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
new file mode 100644
index 00000000000..0a1ac7bb7e9
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Unit tests for the WordPress-facing CapabilityRegistry.
+ *
+ * The unit bootstrap stubs no WordPress functions, so these tests load a
+ * namespaced `apply_filters()` double (see capability-registry-filter-stub.php)
+ * to exercise resolution steps 1 (Core declarations) and 3 (the override
+ * filter). Step 2 - reading a live `WC_Payment_Gateway` instance's `$supports`
+ * array off `WC()->payment_gateways()` - needs a booted WooCommerce and is
+ * covered by integration tests, not here.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Integration\Gateway;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
+
+require_once __DIR__ . '/capability-registry-filter-stub.php';
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry
+ */
+class CapabilityRegistryTest extends TestCase {
+
+	protected function setUp(): void {
+		parent::setUp();
+		GatewayCapabilities::reset();
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls']  = array();
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = null;
+	}
+
+	protected function tearDown(): void {
+		GatewayCapabilities::reset();
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls']  = array();
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = null;
+		parent::tearDown();
+	}
+
+	public function test_declare_compatibility_delegates_to_core(): void {
+		CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
+
+		$this->assertTrue( GatewayCapabilities::is_declared( 'dummy', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_supports_true_for_a_core_declaration(): void {
+		CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
+
+		$this->assertTrue( CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_supports_false_for_an_undeclared_capability(): void {
+		CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
+
+		$this->assertFalse( CapabilityRegistry::supports( 'dummy', GatewayCapabilities::AMOUNT_CHANGES ) );
+	}
+
+	public function test_supports_false_for_an_unknown_gateway(): void {
+		$this->assertFalse( CapabilityRegistry::supports( 'never-declared', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_supports_passes_the_resolution_chain_through_the_filter(): void {
+		CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
+
+		CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING );
+
+		$calls = $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'];
+		$this->assertCount( 1, $calls );
+		$this->assertSame( CapabilityRegistry::CAPABILITY_CHECK_FILTER, $calls[0]['hook'] );
+		// The pre-filter value is the steps-1-2 result: true for a declared cap.
+		$this->assertTrue( $calls[0]['value'] );
+		// Filter args are gateway id, capability, then the order context (null).
+		$this->assertSame( array( 'dummy', GatewayCapabilities::RECURRING, null ), $calls[0]['args'] );
+	}
+
+	public function test_filter_can_override_a_true_to_false(): void {
+		CapabilityRegistry::declare_compatibility( 'dummy', array( GatewayCapabilities::RECURRING ) );
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = false;
+
+		$this->assertFalse( CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING ) );
+	}
+
+	public function test_filter_can_override_a_false_to_true(): void {
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = true;
+
+		$this->assertTrue( CapabilityRegistry::supports( 'undeclared', GatewayCapabilities::AMOUNT_CHANGES ) );
+	}
+
+	public function test_supports_casts_a_non_bool_filter_result(): void {
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] = '1';
+
+		$result = CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING );
+
+		$this->assertIsBool( $result );
+		$this->assertTrue( $result );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
new file mode 100644
index 00000000000..9a39d990c05
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Namespaced `apply_filters()` stub for CapabilityRegistry unit tests.
+ *
+ * Defined in the same namespace as CapabilityRegistry so its unqualified
+ * `apply_filters()` call resolves here instead of WordPress core, which the
+ * unit bootstrap does not load. The stub records each call and returns either a
+ * configured override or the passed-through value (the WordPress default when
+ * no callback is attached).
+ *
+ * Kept in its own file (not the *Test.php class file) so the PSR-12 "one
+ * namespace per file" / "functions separate from OO" sniffs stay satisfied.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway;
+
+if ( ! function_exists( __NAMESPACE__ . '\\apply_filters' ) ) {
+	/**
+	 * Namespaced test double for WordPress `apply_filters()`.
+	 *
+	 * @param string $hook    Filter hook name.
+	 * @param mixed  $value   Value being filtered.
+	 * @param mixed  ...$args Additional filter arguments.
+	 * @return mixed Configured override, or `$value` unchanged.
+	 */
+	function apply_filters( string $hook, $value, ...$args ) {
+		$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'][] = array(
+			'hook'  => $hook,
+			'value' => $value,
+			'args'  => $args,
+		);
+
+		return $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_return'] ?? $value;
+	}
+}