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