Commit 15cbfa4d9b4 for woocommerce
commit 15cbfa4d9b4ec34a0ee2431b017b73cdbbc138cc
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Wed Jun 24 16:41:30 2026 +0200
Add subscriptions engine cycle-chain schema (#65915)
- Make the contract the live source of truth (schedule, latest snapshot refs, totals, stamps); record each billing as an immutable cycle on a `(contract_id, kind)` chain - no chains table, counters derived from the rows
- Dedupe plan and items snapshots by copy-forward into a per-contract typed table
- Add the Core entities (Contract, Cycle, CycleStatus, snapshot value objects) and the Integration layer (SchemaInstaller, ContractRepository, ContractFactory), with DECIMAL(26,8) money and composite-UNIQUE charge idempotency
- Cover with Core unit and wp-env integration tests; PHPStan level 9 clean
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-cycle-chain-schema b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-cycle-chain-schema
new file mode 100644
index 00000000000..276471a3c1a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-subscriptions-engine-cycle-chain-schema
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add the cycle-chain data model: a live contract as the source of truth holding its latest snapshot references, immutable per-billing cycles on a chain as (contract_id, kind), and per-contract snapshots with copy-forward reuse, persisted through a contract repository with targeted cycle access.
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 8924701baf5..db38fb2d872 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -1,12 +1,21 @@
<?php
/**
- * Contract - the stable, customer-facing identity of a subscription. Manages
- * core data for the subscription and enforces lifecycle transitions through
- * {@see ContractStatus}.
+ * Contract - the stable identity of a subscription and the live source of truth
+ * for its current state. Enforces lifecycle transitions through {@see ContractStatus}.
*
- * Money totals are kept as decimal-safe strings; timestamps are GMT strings
- * (`Y-m-d H:i:s`). The payment instrument is exposed as an {@see InstrumentRef}
- * rather than a live payment token.
+ * Being the live source of truth (mutable), it holds the live schedule
+ * (`next_payment_gmt`), the latest snapshot references (`plan_snapshot_id` /
+ * `items_snapshot_id`), and the live config values (the `*_total` totals and the
+ * `*_gmt` stamps). These are live values, not caches of cycles: sync flows one way
+ * down - a live change repoints the contract's snapshot, and a billing cycle freezes
+ * whatever the contract points at now - never cycle -> contract.
+ *
+ * It holds no cycle graph in memory (cycles are fetched on demand), and a chain is
+ * just the pair `(contract_id, kind)` with its counters derived from the cycle rows.
+ * `origin_order_id` is nullable (a manual contract has none; for a checkout contract
+ * it equals cycle 1's `order_id`). Timestamps are GMT strings; money totals are
+ * decimal-safe strings on the storage scale; the payment instrument is exposed as an
+ * {@see InstrumentRef}.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
*/
@@ -16,8 +25,9 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
use DomainException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
defined( 'ABSPATH' ) || exit;
@@ -30,6 +40,7 @@ defined( 'ABSPATH' ) || exit;
final class Contract {
use ScalarCoercion;
+ use MoneyScale;
const SCHEDULE_SOURCE_PRIMITIVE = 'primitive';
const SCHEDULE_SOURCE_GATEWAY = 'gateway';
@@ -73,14 +84,15 @@ final class Contract {
private $selling_plan_id;
/**
- * Foreign key to the order that triggered this contract.
+ * Origin order id, or null for a manual contract. Equals cycle 1's `order_id`
+ * for a checkout contract.
*
- * @var int
+ * @var int|null
*/
private $origin_order_id;
/**
- * Owning extension slug, or null until owner semantics are assigned.
+ * Owning extension slug, or null.
*
* @var string|null
*/
@@ -108,82 +120,90 @@ final class Contract {
private $payment_token_id;
/**
- * Recurring total per cycle (decimal-safe string).
+ * When the contract goes (or went) active. GMT string.
*
* @var string
*/
- private $billing_total;
+ private $start_gmt;
/**
- * Recurring discount per cycle (decimal-safe string).
+ * Live schedule: when the next renewal fires, or null. GMT string. The due scan
+ * keys on this; a billing cycle freezes its period from it.
*
- * @var string
+ * @var string|null
*/
- private $discount_total;
+ private $next_payment_gmt;
/**
- * Recurring shipping per cycle (decimal-safe string).
+ * Latest/live plan snapshot row id, or null.
+ *
+ * @var int|null
+ */
+ private $plan_snapshot_id;
+
+ /**
+ * Latest/live items snapshot row id, or null until one is recorded.
+ *
+ * @var int|null
+ */
+ private $items_snapshot_id;
+
+ /**
+ * Live billing total (the recurring amount), a decimal-safe string.
*
* @var string
*/
- private $shipping_total;
+ private $billing_total;
/**
- * Recurring tax per cycle (decimal-safe string).
+ * Live discount total, a decimal-safe string.
*
* @var string
*/
- private $tax_total;
+ private $discount_total;
/**
- * When the contract goes (or went) active. GMT string.
+ * Live shipping total, a decimal-safe string.
*
* @var string
*/
- private $start_gmt;
+ private $shipping_total;
/**
- * Next renewal attempt, or null. GMT string.
+ * Live tax total, a decimal-safe string.
*
- * @var string|null
+ * @var string
*/
- private $next_payment_gmt;
+ private $tax_total;
/**
- * Last successful renewal payment, or null. GMT string.
+ * When the contract last billed successfully, or null. GMT string.
*
* @var string|null
*/
private $last_payment_gmt;
/**
- * Last attempted renewal cycle regardless of outcome, or null. GMT string.
+ * When the contract last attempted a charge, or null. GMT string.
*
* @var string|null
*/
private $last_attempt_gmt;
/**
- * End of trial window, or null. GMT string.
+ * When the contract's trial ends (or ended), or null. GMT string.
*
* @var string|null
*/
private $trial_end_gmt;
/**
- * Hard end (cancelled / expired / max_cycles reached), or null. GMT string.
+ * When the contract ends (or ended), or null. GMT string.
*
* @var string|null
*/
private $end_gmt;
- /**
- * Count of successfully-paid renewal cycles.
- *
- * @var int
- */
- private $cycle_count;
-
/**
* Who runs renewals: 'primitive' (this engine) or 'gateway'.
*
@@ -213,182 +233,61 @@ final class Contract {
private $meta;
/**
- * Use {@see self::create()} or {@see self::from_storage()}.
- *
- * @param array<string, mixed> $fields Internal field map.
- */
- private function __construct( array $fields ) {
- $this->id = self::coerce_nullable_int( $fields['id'] ?? null );
- $this->status = self::coerce_string( $fields['status'] ?? null );
- $this->customer_id = self::coerce_int( $fields['customer_id'] ?? null );
- $this->currency = self::coerce_string( $fields['currency'] ?? null );
- $this->selling_plan_id = self::coerce_int( $fields['selling_plan_id'] ?? null );
- $this->origin_order_id = self::coerce_int( $fields['origin_order_id'] ?? null );
- $this->extension_slug = self::coerce_nullable_string( $fields['extension_slug'] ?? null );
- $this->payment_method = self::coerce_nullable_string( $fields['payment_method'] ?? null );
- $this->payment_method_title = self::coerce_nullable_string( $fields['payment_method_title'] ?? null );
- $this->payment_token_id = self::coerce_nullable_int( $fields['payment_token_id'] ?? null );
- $this->billing_total = self::coerce_string( $fields['billing_total'] ?? null, '0' );
- $this->discount_total = self::coerce_string( $fields['discount_total'] ?? null, '0' );
- $this->shipping_total = self::coerce_string( $fields['shipping_total'] ?? null, '0' );
- $this->tax_total = self::coerce_string( $fields['tax_total'] ?? null, '0' );
- $this->start_gmt = self::coerce_string( $fields['start_gmt'] ?? null );
- $this->next_payment_gmt = self::coerce_nullable_string( $fields['next_payment_gmt'] ?? null );
- $this->last_payment_gmt = self::coerce_nullable_string( $fields['last_payment_gmt'] ?? null );
- $this->last_attempt_gmt = self::coerce_nullable_string( $fields['last_attempt_gmt'] ?? null );
- $this->trial_end_gmt = self::coerce_nullable_string( $fields['trial_end_gmt'] ?? null );
- $this->end_gmt = self::coerce_nullable_string( $fields['end_gmt'] ?? null );
- $this->cycle_count = self::coerce_int( $fields['cycle_count'] ?? null );
- $this->schedule_source = self::coerce_string( $fields['schedule_source'] ?? null );
- $this->items = self::coerce_items( $fields['items'] ?? null );
- $this->addresses = self::coerce_addresses( $fields['addresses'] ?? null );
- $this->meta = self::coerce_meta( $fields['meta'] ?? null );
- }
-
- /**
- * Filter raw items to retain only array-valued entries.
- *
- * Individual item field structure is not normalized here; each kept entry is
- * passed through as-is.
- *
- * @param mixed $value Raw items value.
- * @return array<int, array<string, mixed>>
- */
- private static function coerce_items( $value ): array {
- $items = array();
- if ( is_array( $value ) ) {
- foreach ( $value as $item ) {
- if ( is_array( $item ) ) {
- $items[] = $item;
- }
- }
- }
- return $items;
- }
-
- /**
- * Coerce raw address rows to a type => fields map.
- *
- * A flat passthrough keyed by address type: only the types actually present
- * (and array-valued) are kept, so a contract with no addresses round-trips to
- * an empty map rather than two empty billing/shipping rows.
- *
- * @param mixed $value Raw addresses value.
- * @return array<string, array<string, mixed>>
- */
- private static function coerce_addresses( $value ): array {
- $addresses = array();
- if ( is_array( $value ) ) {
- foreach ( $value as $type => $address ) {
- if ( is_array( $address ) ) {
- $addresses[ (string) $type ] = $address;
- }
- }
- }
- return $addresses;
- }
-
- /**
- * Coerce raw meta to a string => string map.
- *
- * @param mixed $value Raw meta value.
- * @return array<string, string>
- */
- private static function coerce_meta( $value ): array {
- $meta = array();
- if ( is_array( $value ) ) {
- foreach ( $value as $key => $val ) {
- $meta[ (string) $key ] = is_scalar( $val ) ? (string) $val : '';
- }
- }
- return $meta;
+ * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+ * to its property type; unknown keys are ignored, missing keys take the default.
+ *
+ * @param array<string, mixed> $data Raw attributes keyed by property name.
+ */
+ private function __construct( array $data ) {
+ $this->id = self::coerce_nullable_int( $data['id'] ?? null );
+ $this->status = self::coerce_string( $data['status'] ?? null, ContractStatus::ACTIVE );
+ $this->customer_id = self::coerce_int( $data['customer_id'] ?? null );
+ $this->currency = self::coerce_string( $data['currency'] ?? null );
+ $this->selling_plan_id = self::coerce_int( $data['selling_plan_id'] ?? null );
+ $this->origin_order_id = self::coerce_nullable_int( $data['origin_order_id'] ?? null );
+ $this->extension_slug = self::coerce_nullable_string( $data['extension_slug'] ?? null );
+ $this->payment_method = self::coerce_nullable_string( $data['payment_method'] ?? null );
+ $this->payment_method_title = self::coerce_nullable_string( $data['payment_method_title'] ?? null );
+ $this->payment_token_id = self::coerce_nullable_int( $data['payment_token_id'] ?? null );
+ $this->start_gmt = self::coerce_string( $data['start_gmt'] ?? null );
+ $this->next_payment_gmt = self::coerce_nullable_string( $data['next_payment_gmt'] ?? null );
+ $this->plan_snapshot_id = self::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+ $this->items_snapshot_id = self::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+ $this->billing_total = self::normalize_money( $data['billing_total'] ?? '0' );
+ $this->discount_total = self::normalize_money( $data['discount_total'] ?? '0' );
+ $this->shipping_total = self::normalize_money( $data['shipping_total'] ?? '0' );
+ $this->tax_total = self::normalize_money( $data['tax_total'] ?? '0' );
+ $this->last_payment_gmt = self::coerce_nullable_string( $data['last_payment_gmt'] ?? null );
+ $this->last_attempt_gmt = self::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
+ $this->trial_end_gmt = self::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
+ $this->end_gmt = self::coerce_nullable_string( $data['end_gmt'] ?? null );
+ $this->schedule_source = self::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
+ $this->items = self::coerce_item_rows( $data['items'] ?? null );
+ $this->addresses = self::coerce_address_map( $data['addresses'] ?? null );
+ $this->meta = self::coerce_meta_map( $data['meta'] ?? null );
}
/**
* Build a new, unsaved contract.
*
- * Required: `selling_plan_id` and `origin_order_id` (positive integers),
- * `customer_id` (a non-negative integer; 0 is a guest), and `currency`,
- * `start_gmt` (non-empty strings). These are validated up front so an omitted
- * field fails loud here rather than coercing to a silent `0`/`''` and producing
- * an observably invalid contract. All other fields are optional and default in
- * the constructor.
- *
* @param array<string, mixed> $args Contract attributes.
- * @throws DomainException If a required attribute is missing or invalid.
+ * @throws DomainException If the contract attributes are not valid.
*/
public static function create( array $args ): self {
- // customer_id 0 is a valid guest contract, so it is non-negative rather
- // than strictly positive; an absent key is still rejected (null is not numeric).
- $customer_id = $args['customer_id'] ?? null;
- if ( ! is_numeric( $customer_id ) || (int) $customer_id < 0 ) {
- throw new DomainException(
- 'Contract: customer_id is required and must be a non-negative integer.'
- );
- }
+ // A new contract is always unsaved; never adopt a caller-supplied id.
+ unset( $args['id'] );
- foreach ( array( 'selling_plan_id', 'origin_order_id' ) as $required_id ) {
- $value = $args[ $required_id ] ?? null;
- if ( ! is_numeric( $value ) || (int) $value <= 0 ) {
- throw new DomainException(
- sprintf( 'Contract: %s is required and must be a positive integer.', $required_id )
- );
- }
- }
-
- foreach ( array( 'currency', 'start_gmt' ) as $required_string ) {
- $value = $args[ $required_string ] ?? null;
- if ( ! is_scalar( $value ) || '' === (string) $value ) {
- throw new DomainException(
- sprintf( 'Contract: %s is required and must be a non-empty string.', $required_string )
- );
- }
- }
+ $contract = new self( $args );
- $status = self::coerce_string( $args['status'] ?? null, ContractStatus::ACTIVE );
- if ( ! ContractStatus::is_valid( $status ) ) {
- throw new DomainException(
- sprintf( 'Contract: invalid status "%s".', $status )
- );
+ if ( ! ContractStatus::is_valid( $contract->status ) ) {
+ throw new DomainException( sprintf( 'Contract: invalid status "%s".', $contract->status ) );
}
- $schedule_source = self::coerce_string( $args['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
- if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
- throw new DomainException(
- sprintf( 'Contract: invalid schedule source "%s".', $schedule_source )
- );
+ if ( ! in_array( $contract->schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
+ throw new DomainException( sprintf( 'Contract: invalid schedule source "%s".', $contract->schedule_source ) );
}
- // The constructor is the single coercion boundary; pass raw values through.
- return new self(
- array(
- 'id' => null,
- 'status' => $status,
- 'customer_id' => $args['customer_id'] ?? null,
- 'currency' => $args['currency'] ?? null,
- 'selling_plan_id' => $args['selling_plan_id'] ?? null,
- 'origin_order_id' => $args['origin_order_id'] ?? null,
- 'extension_slug' => $args['extension_slug'] ?? null,
- 'payment_method' => $args['payment_method'] ?? null,
- 'payment_method_title' => $args['payment_method_title'] ?? null,
- 'payment_token_id' => $args['payment_token_id'] ?? null,
- 'billing_total' => $args['billing_total'] ?? null,
- 'discount_total' => $args['discount_total'] ?? null,
- 'shipping_total' => $args['shipping_total'] ?? null,
- 'tax_total' => $args['tax_total'] ?? null,
- 'start_gmt' => $args['start_gmt'] ?? null,
- 'next_payment_gmt' => $args['next_payment_gmt'] ?? null,
- 'last_payment_gmt' => null,
- 'last_attempt_gmt' => null,
- 'trial_end_gmt' => $args['trial_end_gmt'] ?? null,
- 'end_gmt' => null,
- 'cycle_count' => 0,
- 'schedule_source' => $schedule_source,
- 'items' => $args['items'] ?? null,
- 'addresses' => $args['addresses'] ?? null,
- 'meta' => $args['meta'] ?? null,
- )
- );
+ return $contract;
}
/**
@@ -398,60 +297,16 @@ 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 or schedule_source is invalid.
*/
public static function from_storage( array $row, array $items = array(), array $addresses = array(), array $meta = array() ): self {
- // Hydration is a trust boundary: a WordPress database can be mutated outside
- // this engine's flows, and these fields drive money and scheduling math. A
- // corrupted cycle_count or schedule_source is rejected loudly here rather
- // than silently mis-charging a renewal or mis-routing the schedule.
- $cycle_count = self::coerce_nullable_int( $row['cycle_count'] ?? null );
- if ( null === $cycle_count ) {
- throw new DomainException(
- sprintf( 'Contract: stored cycle_count must be an integer, got %s.', gettype( $row['cycle_count'] ?? null ) )
- );
- }
- if ( $cycle_count < 0 ) {
- throw new DomainException(
- sprintf( 'Contract: stored cycle_count must be 0 or greater, got %d.', $cycle_count )
- );
- }
-
- $schedule_source = self::coerce_string( $row['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
- if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
- throw new DomainException(
- sprintf( 'Contract: stored schedule source must be primitive or gateway, got "%s".', $schedule_source )
- );
- }
-
- // The constructor is the single coercion boundary; pass raw row values through.
return new self(
- array(
- 'id' => $row['id'] ?? null,
- 'status' => $row['status'] ?? null,
- 'customer_id' => $row['customer_id'] ?? null,
- 'currency' => $row['currency'] ?? null,
- 'selling_plan_id' => $row['selling_plan_id'] ?? null,
- 'origin_order_id' => $row['origin_order_id'] ?? null,
- 'extension_slug' => $row['extension_slug'] ?? null,
- 'payment_method' => $row['payment_method'] ?? null,
- 'payment_method_title' => $row['payment_method_title'] ?? null,
- 'payment_token_id' => $row['payment_token_id'] ?? null,
- 'billing_total' => $row['billing_total'] ?? null,
- 'discount_total' => $row['discount_total'] ?? null,
- 'shipping_total' => $row['shipping_total'] ?? null,
- 'tax_total' => $row['tax_total'] ?? null,
- 'start_gmt' => $row['start_gmt'] ?? null,
- 'next_payment_gmt' => $row['next_payment_gmt'] ?? null,
- 'last_payment_gmt' => $row['last_payment_gmt'] ?? null,
- 'last_attempt_gmt' => $row['last_attempt_gmt'] ?? null,
- 'trial_end_gmt' => $row['trial_end_gmt'] ?? null,
- 'end_gmt' => $row['end_gmt'] ?? null,
- 'cycle_count' => $cycle_count,
- 'schedule_source' => $schedule_source,
- 'items' => $items,
- 'addresses' => $addresses,
- 'meta' => $meta,
+ array_merge(
+ $row,
+ array(
+ 'items' => $items,
+ 'addresses' => $addresses,
+ 'meta' => $meta,
+ )
)
);
}
@@ -517,9 +372,9 @@ final class Contract {
}
/**
- * Foreign key to the origin order.
+ * Foreign key to the origin order, or null for a manual/admin contract.
*/
- public function get_origin_order_id(): int {
+ public function get_origin_order_id(): ?int {
return $this->origin_order_id;
}
@@ -549,58 +404,126 @@ final class Contract {
}
/**
- * Recurring total per cycle (decimal-safe string).
+ * Next renewal attempt, or null.
+ */
+ public function get_next_payment_gmt(): ?string {
+ return $this->next_payment_gmt;
+ }
+
+ /**
+ * Set the live schedule (when the next renewal fires).
+ *
+ * @param string|null $next_payment_gmt GMT string or null.
+ */
+ public function set_next_payment_gmt( ?string $next_payment_gmt ): void {
+ $this->next_payment_gmt = $next_payment_gmt;
+ }
+
+ /**
+ * Latest/live plan snapshot row id, or null.
+ */
+ public function get_plan_snapshot_id(): ?int {
+ return $this->plan_snapshot_id;
+ }
+
+ /**
+ * Set the latest/live plan snapshot row id.
+ *
+ * @param int|null $plan_snapshot_id Snapshot row id, or null.
+ */
+ public function set_plan_snapshot_id( ?int $plan_snapshot_id ): void {
+ $this->plan_snapshot_id = $plan_snapshot_id;
+ }
+
+ /**
+ * Latest/live items snapshot row id, or null.
+ */
+ public function get_items_snapshot_id(): ?int {
+ return $this->items_snapshot_id;
+ }
+
+ /**
+ * Set the latest/live items snapshot row id.
+ *
+ * @param int|null $items_snapshot_id Snapshot row id, or null.
+ */
+ public function set_items_snapshot_id( ?int $items_snapshot_id ): void {
+ $this->items_snapshot_id = $items_snapshot_id;
+ }
+
+ /**
+ * Live billing total (decimal-safe string).
*/
public function get_billing_total(): string {
return $this->billing_total;
}
/**
- * Recurring discount per cycle (decimal-safe string).
+ * Set the live billing total, normalized to the storage scale.
+ *
+ * @param string $billing_total Money value (decimal string or number).
+ */
+ public function set_billing_total( string $billing_total ): void {
+ $this->billing_total = self::normalize_money( $billing_total );
+ }
+
+ /**
+ * Live discount total (decimal-safe string).
*/
public function get_discount_total(): string {
return $this->discount_total;
}
/**
- * Recurring shipping per cycle (decimal-safe string).
+ * Set the live discount total, normalized to the storage scale.
+ *
+ * @param string $discount_total Money value (decimal string or number).
+ */
+ public function set_discount_total( string $discount_total ): void {
+ $this->discount_total = self::normalize_money( $discount_total );
+ }
+
+ /**
+ * Live shipping total (decimal-safe string).
*/
public function get_shipping_total(): string {
return $this->shipping_total;
}
/**
- * Recurring tax per cycle (decimal-safe string).
+ * Set the live shipping total, normalized to the storage scale.
+ *
+ * @param string $shipping_total Money value (decimal string or number).
*/
- public function get_tax_total(): string {
- return $this->tax_total;
+ public function set_shipping_total( string $shipping_total ): void {
+ $this->shipping_total = self::normalize_money( $shipping_total );
}
/**
- * Next renewal attempt, or null.
+ * Live tax total (decimal-safe string).
*/
- public function get_next_payment_gmt(): ?string {
- return $this->next_payment_gmt;
+ public function get_tax_total(): string {
+ return $this->tax_total;
}
/**
- * Set the next renewal attempt timestamp.
+ * Set the live tax total, normalized to the storage scale.
*
- * @param string|null $next_payment_gmt GMT string or null.
+ * @param string $tax_total Money value (decimal string or number).
*/
- public function set_next_payment_gmt( ?string $next_payment_gmt ): void {
- $this->next_payment_gmt = $next_payment_gmt;
+ public function set_tax_total( string $tax_total ): void {
+ $this->tax_total = self::normalize_money( $tax_total );
}
/**
- * Last successful renewal payment, or null. GMT string.
+ * When the contract last billed successfully, or null. GMT string.
*/
public function get_last_payment_gmt(): ?string {
return $this->last_payment_gmt;
}
/**
- * Set the last successful renewal payment timestamp.
+ * Set when the contract last billed successfully.
*
* @param string|null $last_payment_gmt GMT string or null.
*/
@@ -609,39 +532,58 @@ final class Contract {
}
/**
- * Start timestamp (GMT string).
+ * When the contract last attempted a charge, or null. GMT string.
*/
- public function get_start_gmt(): string {
- return $this->start_gmt;
+ public function get_last_attempt_gmt(): ?string {
+ return $this->last_attempt_gmt;
+ }
+
+ /**
+ * Set when the contract last attempted a charge.
+ *
+ * @param string|null $last_attempt_gmt GMT string or null.
+ */
+ public function set_last_attempt_gmt( ?string $last_attempt_gmt ): void {
+ $this->last_attempt_gmt = $last_attempt_gmt;
}
/**
- * Count of successfully-paid renewal cycles.
+ * When the contract's trial ends (or ended), or null. GMT string.
*/
- public function get_cycle_count(): int {
- return $this->cycle_count;
+ public function get_trial_end_gmt(): ?string {
+ return $this->trial_end_gmt;
}
/**
- * Set the count of successfully-paid renewal cycles.
+ * Set when the contract's trial ends.
*
- * 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 string|null $trial_end_gmt GMT string or null.
+ */
+ public function set_trial_end_gmt( ?string $trial_end_gmt ): void {
+ $this->trial_end_gmt = $trial_end_gmt;
+ }
+
+ /**
+ * When the contract ends (or ended), or null. GMT string.
+ */
+ public function get_end_gmt(): ?string {
+ return $this->end_gmt;
+ }
+
+ /**
+ * Set when the contract ends.
*
- * @param int $cycle_count New cycle count.
- * @throws DomainException If `$cycle_count` is negative.
+ * @param string|null $end_gmt GMT string or null.
*/
- 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 )
- );
- }
+ public function set_end_gmt( ?string $end_gmt ): void {
+ $this->end_gmt = $end_gmt;
+ }
- $this->cycle_count = $cycle_count;
+ /**
+ * Start timestamp (GMT string).
+ */
+ public function get_start_gmt(): string {
+ return $this->start_gmt;
}
/**
@@ -694,18 +636,99 @@ final class Contract {
'payment_method' => $this->payment_method,
'payment_method_title' => $this->payment_method_title,
'payment_token_id' => $this->payment_token_id,
+ 'start_gmt' => $this->start_gmt,
+ 'next_payment_gmt' => $this->next_payment_gmt,
+ 'plan_snapshot_id' => $this->plan_snapshot_id,
+ 'items_snapshot_id' => $this->items_snapshot_id,
'billing_total' => $this->billing_total,
'discount_total' => $this->discount_total,
'shipping_total' => $this->shipping_total,
'tax_total' => $this->tax_total,
- 'start_gmt' => $this->start_gmt,
- 'next_payment_gmt' => $this->next_payment_gmt,
'last_payment_gmt' => $this->last_payment_gmt,
'last_attempt_gmt' => $this->last_attempt_gmt,
'trial_end_gmt' => $this->trial_end_gmt,
'end_gmt' => $this->end_gmt,
- 'cycle_count' => $this->cycle_count,
'schedule_source' => $this->schedule_source,
);
}
+
+ /**
+ * Shape a caller-supplied value into the line-item row list. A non-array yields
+ * no items; non-array elements are skipped.
+ *
+ * @param mixed $value Caller-supplied items.
+ * @return array<int, array<string, mixed>>
+ */
+ private static function coerce_item_rows( $value ): array {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+
+ $rows = array();
+ foreach ( $value as $row ) {
+ if ( is_array( $row ) ) {
+ $rows[] = self::coerce_string_keyed( $row );
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Shape a caller-supplied value into the addresses map keyed by type. A non-array
+ * yields an empty map; non-array elements are skipped.
+ *
+ * @param mixed $value Caller-supplied addresses.
+ * @return array<string, array<string, mixed>>
+ */
+ private static function coerce_address_map( $value ): array {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+
+ $map = array();
+ foreach ( $value as $type => $address ) {
+ if ( is_array( $address ) ) {
+ $map[ (string) $type ] = self::coerce_string_keyed( $address );
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * Shape a caller-supplied value into the meta map (string => string). A non-array
+ * yields an empty map.
+ *
+ * @param mixed $value Caller-supplied meta.
+ * @return array<string, string>
+ */
+ private static function coerce_meta_map( $value ): array {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+
+ $map = array();
+ foreach ( $value as $key => $meta_value ) {
+ $map[ (string) $key ] = self::coerce_string( $meta_value );
+ }
+
+ return $map;
+ }
+
+ /**
+ * Re-key an array as a string-keyed map, recovering the `array<string, mixed>`
+ * row shape from an otherwise `int|string`-keyed array.
+ *
+ * @param array<int|string, mixed> $value Array to re-key.
+ * @return array<string, mixed>
+ */
+ private static function coerce_string_keyed( array $value ): array {
+ $result = array();
+ foreach ( $value as $key => $entry ) {
+ $result[ (string) $key ] = $entry;
+ }
+
+ return $result;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
new file mode 100644
index 00000000000..27e6782449a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -0,0 +1,578 @@
+<?php
+/**
+ * Cycle - one period in a chain `(contract_id, kind)`, where a chain is the pair
+ * `(contract_id, kind)` with counters derived from its rows (not a stored entity).
+ *
+ * An immutable billing record frozen at billing (snapshot references, period
+ * boundaries, `expected_total`); only `status`, `order_id`, and `reason` may change
+ * afterwards. The `count` is the chargeable number and idempotency anchor, nullable
+ * for non-counting cycles (e.g. a future trial). Money is a decimal-safe string;
+ * timestamps are GMT strings (`Y-m-d H:i:s`).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Cycle entity.
+ *
+ * Construct via {@see self::create()} for a new (unsaved) cycle or
+ * {@see self::from_storage()} when hydrating a stored row.
+ */
+final class Cycle {
+
+ use ScalarCoercion;
+ use MoneyScale;
+
+ const KIND_BILLING = 'billing';
+
+ /**
+ * Cycle id, or null before it is persisted.
+ *
+ * @var int|null
+ */
+ private $id;
+
+ /**
+ * Owning contract id.
+ *
+ * @var int
+ */
+ private $contract_id;
+
+ /**
+ * Position within the chain, monotonic from 1.
+ *
+ * @var int
+ */
+ private $sequence_no;
+
+ /**
+ * Chargeable number and per-charge idempotency anchor, or null for a
+ * non-counting cycle.
+ *
+ * @var int|null
+ */
+ private $count;
+
+ /**
+ * Chain kind. Known-but-extensible; only the billing kind is written for now.
+ *
+ * @var string
+ */
+ private $kind;
+
+ /**
+ * Lifecycle status. See {@see CycleStatus}.
+ *
+ * @var CycleStatus
+ */
+ private $status;
+
+ /**
+ * Open-ended annotation, or null.
+ *
+ * @var string|null
+ */
+ private $reason;
+
+ /**
+ * Period start (and, billing-in-advance, the charge date). GMT string.
+ *
+ * @var string
+ */
+ private $starts_at_gmt;
+
+ /**
+ * Period end. GMT string.
+ *
+ * @var string
+ */
+ private $ends_at_gmt;
+
+ /**
+ * Amount expected to be billed for this cycle (decimal-safe string).
+ *
+ * @var string
+ */
+ private $expected_total;
+
+ /**
+ * ISO-4217 currency code.
+ *
+ * @var string
+ */
+ private $currency;
+
+ /**
+ * Plan snapshot row id, or null until the snapshot is stored.
+ *
+ * @var int|null
+ */
+ private $plan_snapshot_id;
+
+ /**
+ * Items snapshot row id, or null until the snapshot is stored.
+ *
+ * @var int|null
+ */
+ private $items_snapshot_id;
+
+ /**
+ * Linked order id (billing or shipping), or null. Not 1:1 - an aggregate order
+ * may serve many cycles.
+ *
+ * @var int|null
+ */
+ private $order_id;
+
+ /**
+ * Owning extension slug, or null.
+ *
+ * @var string|null
+ */
+ private $extension_slug;
+
+ /**
+ * Typed plan snapshot held in memory, or null.
+ *
+ * @var PlanSnapshot|null
+ */
+ private $plan_snapshot;
+
+ /**
+ * Typed items snapshot held in memory, or null.
+ *
+ * @var ItemsSnapshot|null
+ */
+ private $items_snapshot;
+
+ /**
+ * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+ * to its property type; unknown keys are ignored, missing keys take the default.
+ *
+ * @param array<string, mixed> $data Raw attributes keyed by property name.
+ */
+ private function __construct( array $data ) {
+ $this->id = self::coerce_nullable_int( $data['id'] ?? null );
+ $this->contract_id = self::coerce_int( $data['contract_id'] ?? null );
+ $this->sequence_no = self::coerce_int( $data['sequence_no'] ?? null );
+ $this->count = isset( $data['count'] ) ? self::coerce_int( $data['count'] ) : null;
+ $this->kind = self::coerce_string( $data['kind'] ?? null, self::KIND_BILLING );
+ $this->status = self::coerce_status( $data['status'] ?? null );
+ $this->reason = self::coerce_nullable_string( $data['reason'] ?? null );
+ $this->starts_at_gmt = self::coerce_string( $data['starts_at_gmt'] ?? null );
+ $this->ends_at_gmt = self::coerce_string( $data['ends_at_gmt'] ?? null );
+ $this->expected_total = self::normalize_money( $data['expected_total'] ?? '0' );
+ $this->currency = self::coerce_string( $data['currency'] ?? null );
+ $this->plan_snapshot_id = self::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+ $this->items_snapshot_id = self::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+ $this->order_id = self::coerce_nullable_int( $data['order_id'] ?? null );
+ $this->extension_slug = self::coerce_nullable_string( $data['extension_slug'] ?? null );
+ $this->plan_snapshot = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
+ $this->items_snapshot = ( $data['items_snapshot'] ?? null ) instanceof ItemsSnapshot ? $data['items_snapshot'] : null;
+ }
+
+ /**
+ * Build a new, unsaved cycle.
+ *
+ * Required keys: `contract_id`, `sequence_no`, `starts_at_gmt`, `ends_at_gmt`,
+ * `expected_total`, `currency`. Optional: `count` (defaults to 1; pass null for
+ * a non-counting cycle), `status` (defaults to `pending`; a checkout signup
+ * cycle is created directly `billed`), `kind` (defaults to billing), `reason`,
+ * `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`,
+ * `plan_snapshot`, `items_snapshot`.
+ *
+ * @param array<string, mixed> $args Cycle attributes.
+ * @throws DomainException If the attributes are not valid.
+ */
+ public static function create( array $args ): self {
+ if ( ! isset( $args['contract_id'] ) ) {
+ throw new DomainException( 'Cycle: contract_id is required.' );
+ }
+
+ // A new cycle is always unsaved; never adopt a caller-supplied id.
+ unset( $args['id'] );
+
+ // Absent count defaults to 1 (counting); explicit null means non-counting. `?? 1` would conflate them.
+ $args['count'] = self::normalize_count( array_key_exists( 'count', $args ) ? $args['count'] : 1 );
+
+ $cycle = new self( $args );
+
+ self::assert_valid_kind( $cycle->kind );
+ self::assert_valid_sequence_no( $cycle->sequence_no );
+
+ return $cycle;
+ }
+
+ /**
+ * Hydrate from a stored row.
+ *
+ * @param array<string, mixed> $row Cycle row.
+ * @throws DomainException If the stored status, kind, or sequence_no is invalid.
+ */
+ public static function from_storage( array $row ): self {
+ $kind = self::coerce_string( $row['kind'] ?? null, self::KIND_BILLING );
+ self::assert_valid_kind( $kind );
+
+ $sequence_no = self::coerce_int( $row['sequence_no'] ?? null );
+ self::assert_valid_sequence_no( $sequence_no );
+
+ // The typed snapshot value objects are attached on load, never hydrated here.
+ unset( $row['plan_snapshot'], $row['items_snapshot'] );
+
+ $row['count'] = array_key_exists( 'count', $row ) ? self::normalize_count( $row['count'] ) : null;
+
+ return new self( $row );
+ }
+
+ /**
+ * Cycle id, or null before save.
+ */
+ public function get_id(): ?int {
+ return $this->id;
+ }
+
+ /**
+ * Assign the id after a successful insert.
+ *
+ * @param int $id Cycle id.
+ */
+ public function set_id( int $id ): void {
+ $this->id = $id;
+ }
+
+ /**
+ * Owning contract id.
+ */
+ public function get_contract_id(): int {
+ return $this->contract_id;
+ }
+
+ /**
+ * Set the owning contract id. A cycle may be built with a placeholder id (0)
+ * before its contract is persisted; the repository stamps the real id later.
+ *
+ * @param int $contract_id Owning contract id.
+ */
+ public function set_contract_id( int $contract_id ): void {
+ $this->contract_id = $contract_id;
+ }
+
+ /**
+ * Position within the chain.
+ */
+ public function get_sequence_no(): int {
+ return $this->sequence_no;
+ }
+
+ /**
+ * Set the position within the chain (assigned by the append path).
+ *
+ * @param int $sequence_no Position, monotonic from 1.
+ * @throws DomainException If `$sequence_no` is not positive.
+ */
+ public function set_sequence_no( int $sequence_no ): void {
+ self::assert_valid_sequence_no( $sequence_no );
+
+ $this->sequence_no = $sequence_no;
+ }
+
+ /**
+ * Chargeable number within the chain, or null for a non-counting cycle.
+ */
+ public function get_count(): ?int {
+ return $this->count;
+ }
+
+ /**
+ * Cycle kind.
+ */
+ public function get_kind(): string {
+ return $this->kind;
+ }
+
+ /**
+ * Lifecycle status.
+ */
+ public function get_status(): CycleStatus {
+ return $this->status;
+ }
+
+ /**
+ * Transition the cycle to a new status.
+ *
+ * @param CycleStatus $status Target status.
+ * @throws DomainException If the transition is not allowed by CycleStatus.
+ */
+ public function set_status( CycleStatus $status ): void {
+ if ( $this->status->equals( $status ) ) {
+ return;
+ }
+
+ $this->status = $this->status->transition_to( $status );
+ }
+
+ /**
+ * Open-ended annotation, or null.
+ */
+ public function get_reason(): ?string {
+ return $this->reason;
+ }
+
+ /**
+ * Annotate the cycle with an open-ended reason. Always writable.
+ *
+ * @param string|null $reason Reason text, or null to clear.
+ */
+ public function set_reason( ?string $reason ): void {
+ $this->reason = $reason;
+ }
+
+ /**
+ * Period start (GMT string). Frozen at construction.
+ */
+ public function get_starts_at_gmt(): string {
+ return $this->starts_at_gmt;
+ }
+
+ /**
+ * Period end (GMT string). Frozen at construction.
+ */
+ public function get_ends_at_gmt(): string {
+ return $this->ends_at_gmt;
+ }
+
+ /**
+ * Amount expected to be billed (decimal-safe string).
+ */
+ public function get_expected_total(): string {
+ return $this->expected_total;
+ }
+
+ /**
+ * ISO-4217 currency code.
+ */
+ public function get_currency(): string {
+ return $this->currency;
+ }
+
+ /**
+ * Plan snapshot row id, or null.
+ */
+ public function get_plan_snapshot_id(): ?int {
+ return $this->plan_snapshot_id;
+ }
+
+ /**
+ * Record the plan snapshot row id once it is stored. Write-once: only fills an
+ * unset id, never re-points the frozen reference.
+ *
+ * @param int $plan_snapshot_id Snapshot row id.
+ * @throws DomainException If a plan snapshot id is already recorded.
+ */
+ public function set_plan_snapshot_id( int $plan_snapshot_id ): void {
+ $this->assert_snapshot_ref_unset( 'plan_snapshot_id', $this->plan_snapshot_id );
+ $this->plan_snapshot_id = $plan_snapshot_id;
+ }
+
+ /**
+ * Items snapshot row id, or null.
+ */
+ public function get_items_snapshot_id(): ?int {
+ return $this->items_snapshot_id;
+ }
+
+ /**
+ * Record the items snapshot row id once it is stored. Write-once companion to
+ * {@see self::set_plan_snapshot_id()}.
+ *
+ * @param int $items_snapshot_id Snapshot row id.
+ * @throws DomainException If an items snapshot id is already recorded.
+ */
+ public function set_items_snapshot_id( int $items_snapshot_id ): void {
+ $this->assert_snapshot_ref_unset( 'items_snapshot_id', $this->items_snapshot_id );
+ $this->items_snapshot_id = $items_snapshot_id;
+ }
+
+ /**
+ * Order id linked to this cycle, or null.
+ */
+ public function get_order_id(): ?int {
+ return $this->order_id;
+ }
+
+ /**
+ * Owning extension slug, or null.
+ */
+ public function get_extension_slug(): ?string {
+ return $this->extension_slug;
+ }
+
+ /**
+ * Typed plan snapshot held in memory, or null.
+ */
+ public function get_plan_snapshot(): ?PlanSnapshot {
+ return $this->plan_snapshot;
+ }
+
+ /**
+ * Attach the typed plan snapshot value object once. Write-once: only fills an
+ * unset value object, never swaps the frozen snapshot.
+ *
+ * @param PlanSnapshot $plan_snapshot Snapshot value object.
+ * @throws DomainException If a plan snapshot value object is already attached.
+ */
+ public function set_plan_snapshot( PlanSnapshot $plan_snapshot ): void {
+ if ( null !== $this->plan_snapshot ) {
+ throw new DomainException( 'Cycle: the plan snapshot is frozen and cannot be replaced.' );
+ }
+ $this->plan_snapshot = $plan_snapshot;
+ }
+
+ /**
+ * Typed items snapshot held in memory, or null.
+ */
+ public function get_items_snapshot(): ?ItemsSnapshot {
+ return $this->items_snapshot;
+ }
+
+ /**
+ * Attach the typed items snapshot value object once. Write-once companion to
+ * {@see self::set_plan_snapshot()}.
+ *
+ * @param ItemsSnapshot $items_snapshot Snapshot value object.
+ * @throws DomainException If an items snapshot value object is already attached.
+ */
+ public function set_items_snapshot( ItemsSnapshot $items_snapshot ): void {
+ if ( null !== $this->items_snapshot ) {
+ throw new DomainException( 'Cycle: the items snapshot is frozen and cannot be replaced.' );
+ }
+ $this->items_snapshot = $items_snapshot;
+ }
+
+ /**
+ * Serialize the cycle row (excluding the generated id/timestamps). Status is
+ * stored as its plain string value.
+ *
+ * @return array<string, mixed>
+ */
+ public function to_storage(): array {
+ return array(
+ 'contract_id' => $this->contract_id,
+ 'sequence_no' => $this->sequence_no,
+ 'count' => $this->count,
+ 'kind' => $this->kind,
+ 'status' => $this->status->get_value(),
+ 'reason' => $this->reason,
+ 'starts_at_gmt' => $this->starts_at_gmt,
+ 'ends_at_gmt' => $this->ends_at_gmt,
+ 'expected_total' => $this->expected_total,
+ 'currency' => $this->currency,
+ 'plan_snapshot_id' => $this->plan_snapshot_id,
+ 'items_snapshot_id' => $this->items_snapshot_id,
+ 'order_id' => $this->order_id,
+ 'extension_slug' => $this->extension_slug,
+ );
+ }
+
+ /**
+ * Guard a snapshot row-id stamp so it is write-once; a second stamp would
+ * re-point a frozen reference.
+ *
+ * @param string $field Field being stamped, for the error message.
+ * @param int|null $current The currently recorded id (must be null to stamp).
+ * @throws DomainException If the id is already recorded.
+ */
+ private function assert_snapshot_ref_unset( string $field, ?int $current ): void {
+ if ( null !== $current ) {
+ throw new DomainException(
+ sprintf( 'Cycle: "%s" is frozen and cannot be re-pointed.', $field )
+ );
+ }
+ }
+
+ /**
+ * Validate a cycle kind. Known-but-extensible (deliberately not a sealed enum):
+ * any non-empty kind is accepted so a third party may introduce its own.
+ *
+ * @param string $kind Kind to validate.
+ * @throws DomainException If `$kind` is empty.
+ */
+ private static function assert_valid_kind( string $kind ): void {
+ if ( '' === $kind ) {
+ throw new DomainException( 'Cycle: kind must not be empty.' );
+ }
+ }
+
+ /**
+ * Validate a sequence number.
+ *
+ * @param int $sequence_no Sequence number to validate.
+ * @throws DomainException If `$sequence_no` is not positive.
+ */
+ private static function assert_valid_sequence_no( int $sequence_no ): void {
+ if ( $sequence_no < 1 ) {
+ throw new DomainException(
+ sprintf( 'Cycle: sequence_no must be 1 or greater, got %d.', $sequence_no )
+ );
+ }
+ }
+
+ /**
+ * Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
+ * through; null defaults to `pending`; a string is validated via
+ * {@see CycleStatus::from()}.
+ *
+ * @param mixed $status Raw status value (a CycleStatus, null, or a status string).
+ * @return CycleStatus
+ * @throws DomainException If a status string is not a known status.
+ */
+ private static function coerce_status( $status ): CycleStatus {
+ if ( $status instanceof CycleStatus ) {
+ return $status;
+ }
+
+ if ( null === $status ) {
+ return CycleStatus::pending();
+ }
+
+ return CycleStatus::from( self::coerce_string( $status ) );
+ }
+
+ /**
+ * Normalize and validate a chargeable count.
+ *
+ * Null passes through (a non-counting cycle); a present value must be a
+ * positive integer.
+ *
+ * @param mixed $count Raw count value (null, or coercible to int).
+ * @return int|null
+ * @throws DomainException If a present count is not positive.
+ */
+ private static function normalize_count( $count ): ?int {
+ if ( null === $count ) {
+ return null;
+ }
+
+ $count = self::coerce_int( $count );
+ if ( $count < 1 ) {
+ throw new DomainException(
+ sprintf( 'Cycle: count must be 1 or greater when set, got %d.', $count )
+ );
+ }
+
+ return $count;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
new file mode 100644
index 00000000000..672d7f7ff9e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -0,0 +1,222 @@
+<?php
+/**
+ * CycleStatus - the cycle lifecycle state machine, as an immutable value object.
+ * Owns the valid statuses and allowed transitions so an invalid state cannot be
+ * represented. Mirrors {@see ContractStatus}.
+ *
+ * Lifecycle: a cycle is born `pending`, settles to `billed` (terminal) or `failed`,
+ * and any non-settled cycle can be `cancelled` (terminal). `failed` is deliberately
+ * non-terminal so a later change can add a retry edge. Instance methods serve the
+ * entity; the static string helpers operate on raw strings at the storage boundary.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use DomainException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * CycleStatus value object.
+ *
+ * Immutable. Construct via a named factory ({@see self::pending()} etc.) or
+ * {@see self::from()}.
+ */
+final class CycleStatus {
+
+ const PENDING = 'pending';
+ const BILLED = 'billed';
+ const FAILED = 'failed';
+ const CANCELLED = 'cancelled';
+
+ /**
+ * The status string this value wraps.
+ *
+ * @var string
+ */
+ private $value;
+
+ /**
+ * Use a named factory ({@see self::pending()} etc.) or {@see self::from()}.
+ *
+ * @param string $value A known status string.
+ */
+ private function __construct( string $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Build a status value from a known status string.
+ *
+ * @param string $value Status string.
+ * @throws DomainException If `$value` is not a known status.
+ */
+ public static function from( string $value ): self {
+ if ( ! self::is_valid( $value ) ) {
+ throw new DomainException(
+ sprintf( 'CycleStatus: "%s" is not a known status.', $value )
+ );
+ }
+
+ return new self( $value );
+ }
+
+ /**
+ * The `pending` status (charge in flight; values locked at creation).
+ */
+ public static function pending(): self {
+ return new self( self::PENDING );
+ }
+
+ /**
+ * The `billed` status (settled after a successful charge; terminal).
+ */
+ public static function billed(): self {
+ return new self( self::BILLED );
+ }
+
+ /**
+ * The `failed` status (charge declined; non-terminal).
+ */
+ public static function failed(): self {
+ return new self( self::FAILED );
+ }
+
+ /**
+ * The `cancelled` status (closed; terminal).
+ */
+ public static function cancelled(): self {
+ return new self( self::CANCELLED );
+ }
+
+ /**
+ * The wrapped status string (the value stored on the cycle row).
+ */
+ public function get_value(): string {
+ return $this->value;
+ }
+
+ /**
+ * Whether this status is the same as `$other`.
+ *
+ * @param CycleStatus $other Status to compare against.
+ */
+ public function equals( CycleStatus $other ): bool {
+ return $this->value === $other->value;
+ }
+
+ /**
+ * Whether this status may move to `$target`.
+ *
+ * @param CycleStatus $target Target status.
+ */
+ public function can_transition_to( CycleStatus $target ): bool {
+ return self::is_transition_allowed( $this->value, $target->value );
+ }
+
+ /**
+ * Move to `$target`, returning the new status value.
+ *
+ * @param CycleStatus $target Target status.
+ * @throws DomainException If the transition is not allowed.
+ */
+ public function transition_to( CycleStatus $target ): self {
+ self::assert_transition_allowed( $this->value, $target->value );
+
+ return $target;
+ }
+
+ /**
+ * All known statuses, in lifecycle order.
+ *
+ * @return array<int, string>
+ */
+ public static function all(): array {
+ return array(
+ self::PENDING,
+ self::BILLED,
+ self::FAILED,
+ self::CANCELLED,
+ );
+ }
+
+ /**
+ * Whether `$status` is a known status.
+ *
+ * @param string $status Status to check.
+ */
+ public static function is_valid( string $status ): bool {
+ return in_array( $status, self::all(), true );
+ }
+
+ /**
+ * Whether `$status` is terminal (no transitions out).
+ *
+ * @param string $status Status to check.
+ */
+ public static function is_terminal( string $status ): bool {
+ return self::is_valid( $status ) && array() === self::transitions()[ $status ];
+ }
+
+ /**
+ * Whether a cycle may move from `$from` to `$to`. Unknown statuses report false.
+ * Same-status calls also report false; {@see Cycle::set_status()} short-circuits
+ * no-ops before consulting this table.
+ *
+ * @param string $from Current status.
+ * @param string $to Target status.
+ */
+ public static function is_transition_allowed( string $from, string $to ): bool {
+ if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
+ return false;
+ }
+
+ return in_array( $to, self::transitions()[ $from ], true );
+ }
+
+ /**
+ * Whether a cycle 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.
+ *
+ * @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( 'CycleStatus: illegal status transition from "%s" to "%s".', $from, $to )
+ );
+ }
+ }
+
+ /**
+ * Allowed transitions: current status => list of reachable statuses.
+ *
+ * @return array<string, array<int, string>>
+ */
+ private static function transitions(): array {
+ return array(
+ self::PENDING => array( self::BILLED, self::FAILED, self::CANCELLED ),
+ self::BILLED => array(),
+ self::FAILED => array( self::CANCELLED ),
+ self::CANCELLED => array(),
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
new file mode 100644
index 00000000000..3f4b4a5f651
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * MoneyScale - normalize money values to the storage decimal scale.
+ *
+ * Money is stored as DECIMAL(26,8), mirroring WooCommerce's order tables: HPOS
+ * stores `total_amount` / `tax_amount` / `shipping_total_amount` /
+ * `discount_total_amount` as `decimal(26,8)`. That is a storage scale (precision
+ * headroom), not a display precision - amounts are still shown via
+ * `wc_get_price_decimals()`. Normalizing on the way in keeps a value stable across
+ * a save/load round-trip - exact within double precision (~15 significant digits,
+ * which covers every realistic amount), the same float path as core's
+ * `wc_format_decimal()`. Shared by the cycle's `expected_total` and the contract's
+ * live totals. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Money-scale normalization helper.
+ */
+trait MoneyScale {
+
+ use ScalarCoercion;
+
+ /**
+ * Normalize a money value to the storage scale (8 decimals).
+ *
+ * @param mixed $value Money value (decimal string or number).
+ */
+ private static function normalize_money( $value ): string {
+ return number_format( self::coerce_float( $value ?? '0' ), 8, '.', '' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
index 23e2c92802f..818db9f5098 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
@@ -1,15 +1,9 @@
<?php
/**
- * ScalarCoercion - shared helpers for coercing untyped (mixed) values read from
- * decoded storage rows or caller-supplied argument maps into the scalar types the
- * entities and value objects declare.
- *
- * Hydration boundaries receive mixed data (JSON-decoded columns, $wpdb string
- * rows, loosely-typed $args). A blind (int)/(string) cast on such a value is
- * unsafe - an array or object would warn or fatal. These helpers guard first,
- * then cast, returning a documented default when the value is not coercible.
- *
- * Lives in the WordPress-free Core zone.
+ * ScalarCoercion - shared helpers coercing untyped (mixed) values from storage rows
+ * or argument maps into declared scalar types. Each guards before casting (a blind
+ * cast on an array/object would warn or fatal) and returns a default when the value
+ * is not coercible. WordPress-free Core zone.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
*/
@@ -45,11 +39,9 @@ trait ScalarCoercion {
}
/**
- * Coerce a value to an int, falling back to a default when it is not an integer.
- *
- * Only genuine integers and integer-valued strings are accepted; fractional or
- * exponent forms (`1.5`, `1e2`) are rejected rather than silently truncated, so
- * a corrupted identifier or counter falls back instead of changing value.
+ * Coerce a value to an int, falling back when it is not an integer. Only genuine
+ * integers and integer-valued strings pass; fractional/exponent forms (`1.5`,
+ * `1e2`) fall back rather than being silently truncated.
*
* @param mixed $value The raw value.
* @param int $fallback Returned when $value is not an integer.
@@ -81,4 +73,16 @@ trait ScalarCoercion {
return false !== $validated ? $validated : null;
}
+
+ /**
+ * Coerce a value to a float, falling back when it is not numeric. The
+ * money/decimal coercion: numbers and numeric strings (a DECIMAL column reads
+ * back as one) pass; a non-numeric value falls back rather than casting to 0.0.
+ *
+ * @param mixed $value The raw value.
+ * @param float $fallback Returned when $value is not numeric.
+ */
+ private static function coerce_float( $value, float $fallback = 0.0 ): float {
+ return is_numeric( $value ) ? (float) $value : $fallback;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
new file mode 100644
index 00000000000..1c96d8841d4
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * ItemsSnapshot - an immutable, point-in-time copy of the line items a cycle was
+ * billed for. Companion to {@see PlanSnapshot}, referenced by cycles by id.
+ *
+ * NOT content-addressed: identical consecutive item sets are shared by copy-forward,
+ * so this VO carries no canonicalization or hashing. The item list is ordered and
+ * kept as given. `schema_version` is the payload-FORMAT version (how to parse/upcast),
+ * not a content version. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+use DomainException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * ItemsSnapshot value object.
+ *
+ * Immutable. Construct via {@see self::from_items()} (typed data) or
+ * {@see self::from_payload()} (a serialized payload).
+ */
+final class ItemsSnapshot {
+
+ /**
+ * The ordered line items, each a plain associative array.
+ *
+ * @var array<int, array<string, mixed>>
+ */
+ private $items;
+
+ /**
+ * Payload-format version, recorded on the snapshot row. NOT a content version.
+ *
+ * @var int
+ */
+ private $schema_version;
+
+ /**
+ * Use {@see self::from_items()} or {@see self::from_payload()}.
+ *
+ * @param array<int, array<string, mixed>> $items Ordered line items.
+ * @param int $schema_version Payload-format version.
+ * @throws DomainException If the schema version is not positive.
+ */
+ private function __construct( array $items, int $schema_version ) {
+ if ( $schema_version < 1 ) {
+ throw new DomainException(
+ sprintf( 'ItemsSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
+ );
+ }
+
+ $this->items = array_values( $items );
+ $this->schema_version = $schema_version;
+ }
+
+ /**
+ * Build an items snapshot from an ordered list of items.
+ *
+ * @param array<int, array<string, mixed>> $items Ordered line items.
+ * @param int $schema_version Payload-format version. Defaults to 1.
+ * @throws DomainException If the schema version is not positive.
+ */
+ public static function from_items( array $items, int $schema_version = 1 ): self {
+ return new self( $items, $schema_version );
+ }
+
+ /**
+ * Reconstruct an items snapshot from a serialized payload. Companion to
+ * {@see self::to_payload()}.
+ *
+ * @param array<int, array<string, mixed>> $payload Serialized ordered line items.
+ * @param int $schema_version Payload-format version the payload was written in.
+ * @throws DomainException If the schema version is not positive.
+ */
+ public static function from_payload( array $payload, int $schema_version = 1 ): self {
+ return new self( $payload, $schema_version );
+ }
+
+ /**
+ * Payload-format version (NOT a content version).
+ */
+ public function get_schema_version(): int {
+ return $this->schema_version;
+ }
+
+ /**
+ * The line items, in their original order.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public function get_items(): array {
+ return $this->items;
+ }
+
+ /**
+ * The serialized payload for storage: the ordered line items, no
+ * canonicalization (dedup is by copy-forward, not a content hash).
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public function to_payload(): array {
+ return $this->items;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
new file mode 100644
index 00000000000..ed1e64bc5a0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * PlanSnapshot - an immutable, point-in-time copy of the plan terms a cycle was
+ * billed under. Companion to {@see ItemsSnapshot}, referenced by cycles by id.
+ *
+ * NOT content-addressed: identical consecutive plan terms are shared by copy-forward,
+ * so this VO carries no canonicalization or hashing. The typed in-memory form; the
+ * Integration binding serializes via {@see self::to_payload()} and reconstructs via
+ * {@see self::from_payload()}. `schema_version` is the payload-FORMAT version (how to
+ * parse/upcast), not the plan's content version. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * PlanSnapshot value object.
+ *
+ * Immutable. Construct via {@see self::from_array()} (typed data) or
+ * {@see self::from_payload()} (a serialized payload).
+ */
+final class PlanSnapshot {
+
+ use ScalarCoercion;
+
+ /**
+ * The plan terms payload, as stored on the snapshot row.
+ *
+ * @var array<string, mixed>
+ */
+ private $data;
+
+ /**
+ * Payload-format version, recorded on the snapshot row. NOT the plan's content
+ * version.
+ *
+ * @var int
+ */
+ private $schema_version;
+
+ /**
+ * Use {@see self::from_array()} or {@see self::from_payload()}.
+ *
+ * @param array<string, mixed> $data Plan terms payload.
+ * @param int $schema_version Payload-format version.
+ * @throws DomainException If the schema version is not positive.
+ */
+ private function __construct( array $data, int $schema_version ) {
+ if ( $schema_version < 1 ) {
+ throw new DomainException(
+ sprintf( 'PlanSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
+ );
+ }
+
+ $this->data = $data;
+ $this->schema_version = $schema_version;
+ }
+
+ /**
+ * Build a plan snapshot from a payload array.
+ *
+ * @param array<string, mixed> $data Plan terms payload.
+ * @param int $schema_version Payload-format version. Defaults to 1.
+ * @throws DomainException If the schema version is not positive.
+ */
+ public static function from_array( array $data, int $schema_version = 1 ): self {
+ return new self( $data, $schema_version );
+ }
+
+ /**
+ * Reconstruct a plan snapshot from a serialized payload. Companion to
+ * {@see self::to_payload()}.
+ *
+ * @param array<string, mixed> $payload Serialized plan terms payload.
+ * @param int $schema_version Payload-format version the payload was written in.
+ * @throws DomainException If the schema version is not positive.
+ */
+ public static function from_payload( array $payload, int $schema_version = 1 ): self {
+ return new self( $payload, $schema_version );
+ }
+
+ /**
+ * Payload-format version (NOT the plan's content version).
+ */
+ public function get_schema_version(): int {
+ return $this->schema_version;
+ }
+
+ /**
+ * The id of the plan these terms were snapshotted from, or null when absent.
+ * A weak link back to the source plan; a missing key surfaces here as null.
+ */
+ public function get_selling_plan_id(): ?int {
+ return isset( $this->data['selling_plan_id'] ) ? self::coerce_int( $this->data['selling_plan_id'] ) : null;
+ }
+
+ /**
+ * The plan terms payload, in its original key order.
+ *
+ * @return array<string, mixed>
+ */
+ public function to_array(): array {
+ return $this->data;
+ }
+
+ /**
+ * The serialized payload for storage: the plan terms as given, no
+ * canonicalization (dedup is by copy-forward, not a content hash).
+ *
+ * @return array<string, mixed>
+ */
+ public function to_payload(): array {
+ return $this->data;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
index 19f909b65dc..f0baf5a90a0 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -1,24 +1,8 @@
<?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).
+ * Builds and persists a {@see Contract} (plus its origin {@see Cycle}) from a paid
+ * checkout order, and links order <-> contract in both directions. Does not schedule
+ * the first renewal - the caller arms that separately via {@see RenewalEngine::schedule()}.
*
* Integration zone: WordPress-native. Reads a live `WC_Order`; the order never
* crosses into Core - only the snapshot values pulled off it do.
@@ -36,7 +20,12 @@ use Throwable;
use WC_Order;
use WC_Order_Item_Product;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
defined( 'ABSPATH' ) || exit;
@@ -46,6 +35,8 @@ defined( 'ABSPATH' ) || exit;
*/
final class ContractFactory {
+ use ScalarCoercion;
+
/**
* The repository the factory persists through.
*
@@ -67,23 +58,19 @@ final class ContractFactory {
/**
* 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']`.
+ * The live totals (`billing_total` = cycle 1's `expected_total`, plus discount /
+ * shipping / tax) are seeded from the order on the assumption the first recurring
+ * bill equals the order's recurring price; the first renewal date is computed from
+ * the plan's billing policy anchored on the paid time (so a native trial delays it).
+ * Any of these, and any other `Contract::create()` field, can be replaced via `$overrides`.
*
* @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.
+ * @param array<string, mixed> $overrides Optional explicit values: any Contract::create() field, plus
+ * `billing_total` (cycle 1's expected_total) and
+ * `next_payment_gmt` (the first renewal date / cycle 1's period end).
* @return Contract The persisted contract, with its id assigned.
- * @throws \RuntimeException If the plan or order has no id, or the insert fails.
+ * @throws \RuntimeException If the plan or order has no id, or a write fails.
*/
public function create_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): Contract {
$plan_id = $plan->get_id();
@@ -91,59 +78,124 @@ final class ContractFactory {
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.
+ // An unsaved order reports id 0, which 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' ) );
+ : new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+
+ // Start from the paid time (not processing time) so the contract start, cycle 1's
+ // period start, and the renewal-measurement anchor all agree.
+ $period_start = $anchor->format( 'Y-m-d H:i:s' );
+
+ // First renewal date: cycle 1's period end and the contract's next-bill cache.
+ $next_payment = isset( $overrides['next_payment_gmt'] )
+ ? self::coerce_string( $overrides['next_payment_gmt'] )
+ : $plan->get_billing_policy()->compute_first_renewal_from( $anchor )->format( 'Y-m-d H:i:s' );
- $next_payment = $plan->get_billing_policy()
- ->compute_first_renewal_from( $anchor )
- ->format( 'Y-m-d H:i:s' );
+ $expected_total = isset( $overrides['billing_total'] ) ? self::coerce_string( $overrides['billing_total'] ) : (string) $order->get_total();
+ $currency = $order->get_currency();
- $defaults = array(
+ $contract_defaults = array(
'customer_id' => $order->get_customer_id(),
- 'currency' => $order->get_currency(),
+ 'currency' => $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(),
+ 'start_gmt' => $period_start,
+ 'next_payment_gmt' => $next_payment,
+ // Live recurring totals the contract bills going forward, seeded from the order.
+ 'billing_total' => $expected_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 ) );
+ $contract = Contract::create( array_merge( $contract_defaults, $overrides ) );
- $id = $this->contracts->insert( $contract );
+ $origin_cycle = $this->build_origin_cycle( $order, $plan, $period_start, $next_payment, $expected_total, $currency );
- $this->tag_origin_order( $order, $id );
+ $contract_id = $this->contracts->insert_with_origin_cycle( $contract, $origin_cycle );
+
+ $this->tag_origin_order( $order, $contract_id );
return $contract;
}
+ /**
+ * Build the billing chain's cycle 1 - the immutable signup record.
+ *
+ * Created directly `billed` (the origin order is already paid), with `count` 1 and
+ * `contract_id` a placeholder (0) the repository stamps once the contract row has an id.
+ *
+ * @param WC_Order $order The paid checkout order (the items / order-id source).
+ * @param Plan $plan The selling plan (the plan-snapshot / owner source).
+ * @param string $starts_at Cycle 1's period start (the signup time, GMT string).
+ * @param string $ends_at Cycle 1's period end (the first renewal date, GMT string).
+ * @param string $expected_total The amount cycle 1 billed (decimal-safe string).
+ * @param string $currency ISO-4217 currency code.
+ * @return Cycle The unsaved signup cycle, created `billed`.
+ */
+ private function build_origin_cycle( WC_Order $order, Plan $plan, string $starts_at, string $ends_at, string $expected_total, string $currency ): Cycle {
+ return Cycle::create(
+ array(
+ 'contract_id' => 0,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'status' => CycleStatus::billed(),
+ 'order_id' => $order->get_id(),
+ 'extension_slug' => $plan->get_extension_slug(),
+ 'starts_at_gmt' => $starts_at,
+ 'ends_at_gmt' => $ends_at,
+ 'expected_total' => $expected_total,
+ 'currency' => $currency,
+ 'plan_snapshot' => $this->build_plan_snapshot( $plan ),
+ 'items_snapshot' => $this->build_items_snapshot( $order ),
+ )
+ );
+ }
+
+ /**
+ * Build the typed plan snapshot for the origin cycle.
+ *
+ * @param Plan $plan The plan whose terms to snapshot.
+ */
+ private function build_plan_snapshot( Plan $plan ): PlanSnapshot {
+ return PlanSnapshot::from_array(
+ array(
+ 'selling_plan_id' => $plan->get_id(),
+ 'name' => $plan->get_name(),
+ 'category' => $plan->get_category(),
+ 'billing_policy' => $plan->get_billing_policy()->to_array(),
+ )
+ );
+ }
+
+ /**
+ * Build the typed items snapshot for the origin cycle from the order.
+ *
+ * @param WC_Order $order The order whose line items to snapshot.
+ */
+ private function build_items_snapshot( WC_Order $order ): ItemsSnapshot {
+ return ItemsSnapshot::from_items( $this->map_items( $order ) );
+ }
+
/**
* 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.
+ * Best-effort: the contract already carries the `origin_order_id` FK, so a failure
+ * here is logged and swallowed (the order-side link can be rebuilt from the FK later).
*
* @param WC_Order $order Order to tag.
* @param int $contract_id Contract id to write into the order meta.
@@ -173,11 +225,9 @@ final class ContractFactory {
/**
* 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.
+ * Only `line_item` rows are carried (fees / shipping / tax are reconstructed from
+ * the contract totals at renewal). These are a snapshot for inspection, not the
+ * renewal source of truth - the renewal-order builder clones the origin order's items.
*
* @param WC_Order $order The order to read items from.
* @return array<int, array<string, mixed>>
@@ -221,13 +271,10 @@ final class ContractFactory {
/**
* 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.
+ * Reads WooCommerce's per-order payment tokens (populated when a gateway calls
+ * `$order->add_payment_token()`); the last entry is the one charged. Returns null
+ * when none is resolvable (manual gateways, or token stored elsewhere) - the contract
+ * is then created without a token and a later payment-method change can attach one.
*
* @param WC_Order $order Order to read the token from.
* @return int|null Token id, or null when none is resolvable.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
index 87ceaee9c68..c1003e13d2e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -1,21 +1,12 @@
<?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
+ * Order-side meta keys linking orders to contracts, making the relationship
+ * queryable from the order side (the contract row carries the reverse
+ * `origin_order_id`). The engine owns these keys; consumers 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.
+ * Integration zone: WordPress-native. Written to WooCommerce order meta, which
+ * works under both HPOS and the legacy CPT order store.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
*/
@@ -36,8 +27,7 @@ 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 )`.
+ * Stored as a stringified integer (order meta is a flat string table).
*/
const META_CONTRACT_ID = '_subscription_contract_id';
@@ -47,9 +37,7 @@ final class OrderLinkage {
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.
+ * The order whose checkout created the contract (the contract's `origin_order_id`).
*/
const RELATION_PARENT = 'parent';
@@ -59,13 +47,12 @@ final class OrderLinkage {
const RELATION_RENEWAL = 'renewal';
/**
- * A switch order - customer moved between plans (later milestone).
+ * A switch order - customer moved between plans.
*/
const RELATION_SWITCH = 'switch';
/**
- * A resubscribe order - customer restarted a previously-cancelled contract
- * (later milestone).
+ * A resubscribe order - customer restarted a previously-cancelled contract.
*/
const RELATION_RESUBSCRIBE = 'resubscribe';
@@ -84,10 +71,8 @@ final class OrderLinkage {
}
/**
- * 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.
+ * Throw if `$relation` is not one of the known relation types, so a typoed
+ * relation fails loudly rather than silently querying to an empty result.
*
* @param string $relation Candidate relation type.
* @throws InvalidArgumentException If `$relation` is not recognized.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
index e764542ecb1..acaf96ec851 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -1,23 +1,14 @@
<?php
/**
- * RenewalEngine - the seam between consumers and the renewal money-path.
+ * The seam between consumers and the renewal money-path: `schedule()` enqueues a
+ * contract's next renewal, `process_due()` runs it when Action Scheduler fires.
+ * Wraps Action Scheduler (whose hook names and dedup behaviour stay private) and
+ * adds the contract-aware semantics: capability gating, the renewal order, the charge.
+ * One AS job per contract; the AS coupling lives in {@see RenewalScheduler}.
*
- * `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.
+ * Advancing the chain at fire time (appending the next cycle, recording the outcome,
+ * advancing `next_payment_gmt`, re-arming the next due moment) is the dispatcher
+ * slice's money-path and is not built here, so this unit does not drive a live loop.
*
* Integration zone: WordPress-native. Action Scheduler, WC orders, gateways.
*
@@ -35,11 +26,9 @@ 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;
@@ -65,36 +54,24 @@ final class RenewalEngine {
const LOG_SOURCE = 'woocommerce-subscriptions-engine';
/**
- * Repository for loading and persisting contracts.
+ * Repository for loading and persisting contracts, and targeted cycle access.
*
* @var ContractRepository
*/
private $contracts;
/**
- * Repository for loading plans (the BillingPolicy source).
- *
- * @var PlanRepository
- */
- private $plans;
-
- /**
- * Build a renewal engine over the given repositories.
+ * Build a renewal engine over the given contract repository.
*
* @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 ) {
+ public function __construct( ?ContractRepository $contracts = 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}.
+ * Register the Action Scheduler callback. Must run on every page load (not just
+ * activation) so AS can dispatch a due action back into {@see self::handle_due_action()}.
*/
public static function register_hooks(): void {
add_action( RenewalScheduler::HOOK, array( __CLASS__, 'handle_due_action' ), 10, 1 );
@@ -103,9 +80,8 @@ final class RenewalEngine {
/**
* 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.
+ * Static so it can be registered as a plain callback; routes through the instance
+ * `process_due()` so dispatch and any synchronous test driver share one code path.
*
* @param int $contract_id Contract whose renewal is firing.
*/
@@ -114,24 +90,15 @@ final class RenewalEngine {
}
/**
- * 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.
+ * Schedule (or re-schedule) the next renewal for `$contract` at its `next_payment_gmt`.
*
- * Does nothing when the contract has no `next_payment_gmt` (nothing to
- * anchor on) - any stale row is cleared.
+ * Clear-then-enqueue keeps at most one pending AS row per contract, so callers
+ * moving the date forward just call `schedule()` again. Skips (and clears any stale
+ * row) when the contract is gateway-scheduled (the gateway runs its own schedule) or
+ * has no `next_payment_gmt`. Capability gate: a primitive-scheduled contract is only
+ * enqueued when its gateway declares the `recurring` capability via
+ * {@see CapabilityRegistry::supports()}, so renewals nothing can charge are refused
+ * at the boundary rather than failing later on a customer-facing order.
*
* @param Contract $contract Contract to schedule. Must have an id.
* @return bool True when an AS row was enqueued; false when scheduling was
@@ -143,8 +110,7 @@ final class RenewalEngine {
return false;
}
- // Gateway-scheduled: the gateway owns the schedule. Clear any stale
- // primitive row and bail.
+ // Gateway-scheduled: the gateway owns the schedule. Clear any stale row and bail.
if ( Contract::SCHEDULE_SOURCE_GATEWAY === $contract->get_schedule_source() ) {
RenewalScheduler::unschedule( $id );
return false;
@@ -177,9 +143,8 @@ final class RenewalEngine {
$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.
+ // Clear-then-enqueue: AS does not dedup on hook+args, so without the clear a
+ // re-schedule would leave two rows and fire twice.
RenewalScheduler::unschedule( $id );
RenewalScheduler::schedule( $id, $when );
@@ -191,22 +156,12 @@ final class RenewalEngine {
/**
* 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.
+ * Loads the contract and skips (logging only, never throwing - AS would retry a
+ * permanent failure forever) when it is gone, gateway-scheduled, or not active.
+ * Then guards idempotency - if a renewal order for the next chargeable number in
+ * the billing chain already exists, no second one is created (tolerating AS retries) -
+ * and builds the renewal order and attempts the gateway charge. Advancing the chain
+ * is the dispatcher slice's money-path, so this does not drive a live renewal loop.
*
* @param int $contract_id Contract whose renewal cycle is firing.
* @return WC_Order|null The created renewal order, or null when skipped.
@@ -247,12 +202,13 @@ final class RenewalEngine {
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 ) ) {
+ $next_count = $this->next_chargeable_count( $contract_id );
+
+ // Idempotency: a renewal order already tagged for this number means the action
+ // already ran (AS retry, double-fire). Bail without creating a second order.
+ if ( $this->renewal_exists_for_cycle( $contract_id, $next_count ) ) {
wc_get_logger()->info(
- sprintf( 'RenewalEngine::process_due(): renewal for contract %d cycle %d already exists - skipping (idempotent retry).', $contract_id, $contract->get_cycle_count() + 1 ),
+ sprintf( 'RenewalEngine::process_due(): renewal for contract %d cycle %d already exists - skipping (idempotent retry).', $contract_id, $next_count ),
array(
'source' => self::LOG_SOURCE,
'contract_id' => $contract_id,
@@ -261,27 +217,18 @@ final class RenewalEngine {
return null;
}
- $renewal_order = $this->build_renewal_order( $contract );
+ $renewal_order = $this->build_renewal_order( $contract, $next_count );
if ( null === $renewal_order ) {
- // build_renewal_order logged the reason; do not advance a contract
- // whose renewal we could not create.
+ // build_renewal_order logged the reason.
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 );
- }
+ // Advancing the chain and re-arming the next due moment is the dispatcher
+ // slice's money-path - deferred, so the loop is not driven from here.
return $renewal_order;
}
@@ -290,9 +237,7 @@ final class RenewalEngine {
* 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.
+ * which raises a `DomainException` on an illegal transition.
*
* @param Contract $contract Contract to cancel. Must have an id.
* @return bool True when the contract was cancelled and persisted.
@@ -305,6 +250,8 @@ final class RenewalEngine {
}
$contract->set_status( ContractStatus::CANCELLED );
+ // update() persists the contract-row cache/status ONLY. Closing the scheduled
+ // cycle and any chain/cycle head transition is the dispatcher money-path.
$this->contracts->update( $contract );
RenewalScheduler::unschedule( $id );
@@ -313,75 +260,49 @@ final class RenewalEngine {
}
/**
- * 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.
+ * The next chargeable number in the contract's billing chain - one past the
+ * per-chain `MAX(count)`, or 1 for a chain with no counting cycle yet. This is
+ * the idempotency anchor the renewal order is tagged with.
*
- * @param Contract $contract Contract to advance in place.
+ * @param int $contract_id Contract id.
+ * @return int The next chargeable number.
*/
- 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() );
+ private function next_chargeable_count( int $contract_id ): int {
+ $max = $this->contracts->max_count( $contract_id );
- $contract->set_cycle_count( $new_count );
- $contract->set_last_payment_gmt( $now->format( 'Y-m-d H:i:s' ) );
+ return null === $max ? 1 : $max + 1;
+ }
- 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() ),
+ /**
+ * Build a renewal order cloned from the contract's origin order: clones
+ * line/fee/shipping/tax/coupon items and addresses, applies the current cycle's
+ * expected total as ground truth, attaches the contract's payment token, and tags
+ * the renewal relation meta (contract id + chargeable number) so charge observers
+ * and the idempotency check can find it. Returns null (logged) when the origin
+ * order cannot be loaded or `wc_create_order()` fails.
+ *
+ * @param Contract $contract Contract being renewed.
+ * @param int $count The chargeable number this order bills.
+ * @return WC_Order|null The saved pending renewal order, or null on failure.
+ */
+ private function build_renewal_order( Contract $contract, int $count ): ?WC_Order {
+ $origin_order_id = $contract->get_origin_order_id();
+ if ( null === $origin_order_id ) {
+ // A manual/admin contract has no origin order to clone from - not supported yet.
+ wc_get_logger()->error(
+ sprintf( 'RenewalEngine: cannot build renewal for contract %d - it has no origin order to clone.', (int) $contract->get_id() ),
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;
+ return null;
}
- // 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() );
+ $origin = wc_get_order( $origin_order_id );
if ( ! $origin instanceof WC_Order ) {
wc_get_logger()->error(
- sprintf( 'RenewalEngine: cannot build renewal for contract %d - origin order %d not found.', (int) $contract->get_id(), $contract->get_origin_order_id() ),
+ sprintf( 'RenewalEngine: cannot build renewal for contract %d - origin order %d not found.', (int) $contract->get_id(), $origin_order_id ),
array(
'source' => self::LOG_SOURCE,
'contract_id' => (int) $contract->get_id(),
@@ -422,21 +343,18 @@ final class RenewalEngine {
$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.
+ // `set_id( 0 )` turns each clone into a fresh row on the renewal order rather
+ // than UPDATE-ing the origin's row.
foreach ( $origin->get_items( array( 'line_item', 'fee', 'shipping', 'tax', 'coupon' ) ) as $item ) {
$clone = clone $item;
$clone->set_id( 0 );
$renewal_order->add_item( $clone );
}
- // 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() );
+ // The current cycle's expected_total is the price authority - applied after
+ // add_item() so the line items do not recompute over it. Reconstructing the
+ // granular discount/shipping/tax breakdown is the dispatcher money-path's job.
+ $renewal_order->set_total( $this->current_cycle_total( (int) $contract->get_id() ) );
$token_id = $instrument->get_token_id();
if ( null !== $token_id ) {
@@ -446,29 +364,38 @@ final class RenewalEngine {
}
}
- // Tag with the renewal relation + the cycle this order bills, so the
- // idempotency check can detect a duplicate fire for the same cycle.
+ // Tag the renewal relation + chargeable number so the idempotency check can
+ // detect a duplicate fire for the same number.
$renewal_order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract->get_id() );
$renewal_order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
- $renewal_order->update_meta_data( self::renewal_cycle_meta_key(), (string) ( $contract->get_cycle_count() + 1 ) );
+ $renewal_order->update_meta_data( self::renewal_cycle_meta_key(), (string) $count );
$renewal_order->save();
return $renewal_order;
}
+ /**
+ * The amount the contract's current cycle expects to bill (its `expected_total`).
+ * A contract with no cycle yet yields '0', making the charge a no-op rather than a
+ * fatal - the safe state for a contract with nothing to bill.
+ *
+ * @param int $contract_id The contract being renewed.
+ * @return string Decimal-safe amount string.
+ */
+ private function current_cycle_total( int $contract_id ): string {
+ $cycle = $this->contracts->find_current_cycle( $contract_id );
+
+ return null === $cycle ? '0' : $cycle->get_expected_total();
+ }
+
/**
* 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.
+ * Fires `woocommerce_subscriptions_engine_scheduled_payment_{gateway}` so the
+ * registered gateway integration captures against the stored token; the engine does
+ * not charge itself. A gateway that registers no handler leaves the order `pending`
+ * (uncharged) - the safe state when it cannot actually charge.
*
* @param WC_Order $renewal_order The pending renewal order to charge.
* @param Contract $contract The contract being renewed.
@@ -483,21 +410,17 @@ final class RenewalEngine {
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.
+ * Fires to request a recurring charge for a renewal order. The gateway (or its
+ * adapter) captures against the stored token, then transitions the order via its
+ * own `payment_complete()` / failure handling.
*
* @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.
+ // A throwing gateway handler must not leave the AS action in a retry-forever
+ // loop. 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(
@@ -510,16 +433,13 @@ final class RenewalEngine {
}
/**
- * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists.
+ * Whether a renewal order tagged for `$contract_id` at `$cycle` already exists -
+ * the idempotency check for AS retries.
*
- * 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.
+ * Queries on the contract id via the flat `meta_key` / `meta_value` shortcut, then
+ * narrows by relation type and cycle in PHP. The flat shortcut is used rather than a
+ * `meta_query` because the legacy CPT order store rejects `meta_query` with
+ * `wc_doing_it_wrong`; the shortcut round-trips through both stores.
*
* @param int $contract_id Contract id.
* @param int $cycle The cycle number the renewal would bill.
@@ -535,9 +455,8 @@ final class RenewalEngine {
)
);
- // This query does not paginate, so wc_get_orders() returns a plain list of
- // orders. The guard narrows the declared WC_Order[]|stdClass return type and
- // treats any unexpected non-array result as "no matching renewal".
+ // Unpaginated, so wc_get_orders() returns a plain list. The guard narrows the
+ // declared return type and treats any non-array result as "no matching renewal".
if ( ! is_array( $orders ) ) {
return false;
}
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 fbb129464bc..1adb1460296 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -1,10 +1,15 @@
<?php
/**
- * ContractRepository - persistence for {@see Contract} entities.
+ * Persistence for the live {@see Contract} (row + items / addresses / meta) plus
+ * targeted cycle access. Owns the $wpdb access across the contract-side tables.
*
- * Lives in the integration layer: it owns the $wpdb access and spans the four
- * contract tables (contract row, items, addresses, meta), hydrating the Core
- * entity from clean arrays.
+ * The contract is the live source of truth. A chain is NOT a stored entity: it is
+ * the pair `(contract_id, kind)`, with its head and counters derived from the cycle
+ * rows. The entity never carries a cycle graph in memory, so cycles are reached
+ * through purpose-built reads ({@see self::find_current_cycle()}, {@see self::max_count()},
+ * etc.) and written one at a time ({@see self::append_cycle()}, {@see self::update_cycle()}).
+ * There is no whole-graph `save()`. Snapshots are deduped by copy-forward (reuse the
+ * previous cycle's snapshot id when plan / items are unchanged), via {@see SnapshotStore}.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
*/
@@ -14,12 +19,16 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
defined( 'ABSPATH' ) || exit;
/**
- * Contract repository.
+ * Live contract repository with targeted cycle access.
*/
final class ContractRepository {
@@ -44,9 +53,31 @@ final class ContractRepository {
'phone',
);
+ /**
+ * The per-contract typed snapshot store.
+ *
+ * @var SnapshotStore
+ */
+ private $snapshots;
+
+ /**
+ * Build a repository over the given snapshot store.
+ *
+ * @param SnapshotStore|null $snapshots Snapshot store; a default instance is
+ * created when omitted.
+ */
+ public function __construct( ?SnapshotStore $snapshots = null ) {
+ $this->snapshots = $snapshots ?? new SnapshotStore();
+ }
+
/**
* Insert a new contract and its items, addresses, and meta.
*
+ * Durable-intent-first (parent row, then children) and the seam a later
+ * transaction-handling change wraps; it does not open a transaction now (a naive
+ * one would commit the integration suite's outer test transaction, and bare
+ * savepoints error in production without an ambient transaction).
+ *
* @param Contract $contract Contract to insert.
* @return int The new contract id.
* @throws \RuntimeException If the contract insert fails.
@@ -83,100 +114,77 @@ final class ContractRepository {
return $id;
}
+ /**
+ * Insert a contract together with its signup cycle (cycle 1) - the checkout
+ * create path.
+ *
+ * Durable-intent-first: insert the contract -> freeze the signup cycle's snapshots
+ * (which need the contract id) -> record those ids on the contract and update its
+ * row -> insert cycle 1 (which carries the same snapshot ids by construction). The
+ * cycle is taken as built by the caller; this only stamps its contract id, resolves
+ * its snapshots, and inserts it. The seam a later transaction-handling change wraps.
+ *
+ * @param Contract $contract The contract to insert.
+ * @param Cycle $cycle The signup cycle (cycle 1), carrying its snapshot value objects.
+ * @return int The new contract id.
+ * @throws \RuntimeException If a contract, snapshot, or cycle write fails.
+ */
+ public function insert_with_origin_cycle( Contract $contract, Cycle $cycle ): int {
+ $contract_id = $this->insert( $contract );
+
+ // First cycle in its chain: no previous to copy-forward from, so its snapshots
+ // are inserted fresh and their ids stamped onto it.
+ $cycle->set_contract_id( $contract_id );
+ $this->resolve_cycle_snapshots( $cycle, null );
+
+ // Record the signup snapshots as the contract's latest/live references, then
+ // persist the contract row before the cycle row (durable-intent-first).
+ $contract->set_plan_snapshot_id( $cycle->get_plan_snapshot_id() );
+ $contract->set_items_snapshot_id( $cycle->get_items_snapshot_id() );
+ $this->update_contract_row( $contract );
+
+ $this->insert_cycle( $cycle );
+
+ return $contract_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.
+ * Updates the contract row in place, then reconciles items / addresses / meta only
+ * when they differ - so the common renewal-cache write (status, next_payment_gmt)
+ * does not churn child rows. The write seam a later transaction-handling change
+ * wraps; no transaction now (see {@see self::insert()}).
*
* @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->assert_contract_exists( $id );
- $this->insert_items( $id, $contract->get_items() );
- $this->insert_addresses( $id, $contract->get_addresses() );
- $this->insert_meta( $id, $contract->get_meta() );
+ $this->update_contract_row( $contract );
+ $this->sync_children( $contract );
- // `$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.
+ * Fetch a contract by id, hydrating the live entity with its items / addresses /
+ * meta. Cycles are NOT hydrated - they are reached on demand through the targeted
+ * cycle reads. For list / guard paths that do not need the children, use
+ * {@see self::find_summary()}.
*
* @param int $id Contract id.
* @return Contract|null Hydrated contract, or null if not found.
*/
public function find( int $id ): ?Contract {
- global $wpdb;
-
- $contracts = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$contracts} WHERE id = %d", $id ), ARRAY_A );
-
+ $row = $this->find_contract_row( $id );
if ( null === $row ) {
return null;
}
@@ -190,7 +198,41 @@ final class ContractRepository {
}
/**
- * Delete a contract and its child rows.
+ * Lightweight read: the contract row only, no children. The row IS the live state,
+ * so list screens and guards that need only identity + schedule avoid loading children.
+ *
+ * @param int $id Contract id.
+ * @return Contract|null The contract (row only), or null if not found.
+ */
+ public function find_summary( int $id ): ?Contract {
+ $row = $this->find_contract_row( $id );
+ if ( null === $row ) {
+ return null;
+ }
+
+ return Contract::from_storage( $row );
+ }
+
+ /**
+ * Whether a contract row exists for `$id`.
+ *
+ * @param int $id Contract id.
+ */
+ public function exists( int $id ): bool {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $found = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE id = %d", $id ) );
+
+ return null !== $found;
+ }
+
+ /**
+ * Delete a contract and its child + cycle rows.
+ *
+ * Snapshot rows are per contract, so they are deleted with the contract too.
*
* @param int $id Contract id.
* @return bool True when the contract row was removed.
@@ -199,6 +241,8 @@ final class ContractRepository {
global $wpdb;
foreach ( array(
+ SchemaInstaller::TABLE_CYCLES,
+ SchemaInstaller::TABLE_SNAPSHOTS,
SchemaInstaller::TABLE_CONTRACT_ITEMS,
SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
SchemaInstaller::TABLE_CONTRACT_META,
@@ -213,6 +257,508 @@ final class ContractRepository {
return (bool) $deleted;
}
+ /**
+ * Append a cycle to its chain `(contract_id, kind)`, copy-forwarding snapshots.
+ *
+ * Resolves the cycle's snapshots (reused from `$previous` when unchanged, else
+ * inserted fresh) then inserts the cycle row and stamps the generated id back onto
+ * the entity. The seam a later transaction-handling change wraps.
+ *
+ * @param Cycle $cycle Cycle to append. Carries its contract id and kind.
+ * @param Cycle|null $previous The chain's previous cycle, when copy-forward of its
+ * snapshot ids should be considered; null for the first cycle.
+ * @throws \RuntimeException If a snapshot or cycle write fails (e.g. a duplicate
+ * (contract_id, kind, sequence_no) the UNIQUE index rejects).
+ */
+ public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
+ $this->resolve_cycle_snapshots( $cycle, $previous );
+ $this->insert_cycle( $cycle );
+ }
+
+ /**
+ * Update an existing cycle row.
+ *
+ * @param Cycle $cycle Cycle to update. Must have an id whose row still exists.
+ * @throws \RuntimeException If the cycle has no id, the update fails, or its row
+ * no longer exists (stale id or concurrent delete).
+ */
+ public function update_cycle( Cycle $cycle ): void {
+ global $wpdb;
+
+ $id = $cycle->get_id();
+ if ( null === $id ) {
+ throw new \RuntimeException( 'Cannot update a cycle that has no id. Use ContractRepository::append_cycle() for a new cycle.' );
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $updated = $wpdb->update(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ),
+ array_merge(
+ $cycle->to_storage(),
+ array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
+ ),
+ array( 'id' => (int) $id )
+ );
+
+ // date_updated_gmt always changes, so a matched row reports 1 affected row; 0
+ // means no row matched (stale id or concurrent delete) and must fail loudly.
+ if ( false === $updated || 0 === $updated ) {
+ throw new \RuntimeException( 'Cycle row not found during update (concurrent delete or stale id).' );
+ }
+ }
+
+ /**
+ * The chain's most-recent cycle (highest `sequence_no` in `(contract_id, kind)`),
+ * or null when the chain is empty. Snapshots are decoded into typed value objects
+ * only for an in-flight cycle (see {@see self::hydrate_cycle()}).
+ *
+ * @param int $contract_id Contract id.
+ * @param string $kind Chain kind. Defaults to billing.
+ * @return Cycle|null The most-recent cycle, or null if the chain has none.
+ */
+ public function find_current_cycle( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?Cycle {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d AND kind = %s ORDER BY sequence_no DESC LIMIT 1", $contract_id, $kind ), ARRAY_A );
+
+ if ( null === $row ) {
+ return null;
+ }
+
+ return $this->hydrate_cycle( $row );
+ }
+
+ /**
+ * A window of a chain's cycle history, newest first - the paginated read for
+ * history screens. Snapshots are decoded only for any in-flight cycle in the window.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $kind Chain kind. Defaults to billing.
+ * @param int $limit Maximum rows to return.
+ * @param int $offset Rows to skip (for paging).
+ * @return array<int, Cycle> Cycles newest first.
+ */
+ public function find_cycle_history( int $contract_id, string $kind = Cycle::KIND_BILLING, int $limit = 20, int $offset = 0 ): array {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d AND kind = %s ORDER BY sequence_no DESC LIMIT %d OFFSET %d", $contract_id, $kind, $limit, $offset ), ARRAY_A );
+
+ $cycles = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ if ( is_array( $row ) ) {
+ $cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+ }
+ }
+
+ return $cycles;
+ }
+
+ /**
+ * The highest `count` in a chain `(contract_id, kind)` - the chargeable counter the
+ * dispatcher advances (next chargeable cycle is `MAX(count) + 1`). Returns null for a
+ * chain with no counting cycles (e.g. one holding only non-counting trial periods).
+ *
+ * @param int $contract_id Contract id.
+ * @param string $kind Chain kind. Defaults to billing.
+ * @return int|null The highest count, or null when the chain has no counting cycle.
+ */
+ public function max_count( int $contract_id, string $kind = Cycle::KIND_BILLING ): ?int {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $max = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(count) FROM {$table} WHERE contract_id = %d AND kind = %s", $contract_id, $kind ) );
+
+ return null === $max ? null : (int) $max;
+ }
+
+ /**
+ * All cycles linked to `$order_id`, across kinds and contracts. `order_id` is a
+ * non-1:1 reference (an aggregate order may serve many cycles), so this returns
+ * every match. Snapshots are decoded for any in-flight cycle.
+ *
+ * @param int $order_id WooCommerce order id.
+ * @return array<int, Cycle> Cycles linked to the order, oldest first.
+ */
+ public function find_cycles_by_order_id( int $order_id ): array {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );
+
+ $cycles = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ if ( is_array( $row ) ) {
+ $cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+ }
+ }
+
+ return $cycles;
+ }
+
+ /**
+ * Resolve a cycle's in-memory snapshot value objects into stored row ids (write-once).
+ *
+ * Per snapshot type: when the cycle carries a fresh value object and no stored id
+ * yet, the id is copy-forwarded from `$previous` (when payload + schema version
+ * match) or freshly inserted, then stamped onto the cycle. A cycle that already
+ * carries an id is left as is - a frozen reference is never re-pointed.
+ *
+ * @param Cycle $cycle Cycle whose snapshots to resolve.
+ * @param Cycle|null $previous The previous cycle to copy-forward from, or null.
+ */
+ private function resolve_cycle_snapshots( Cycle $cycle, ?Cycle $previous ): void {
+ $contract_id = $cycle->get_contract_id();
+
+ $plan = $cycle->get_plan_snapshot();
+ if ( $plan instanceof PlanSnapshot && null === $cycle->get_plan_snapshot_id() ) {
+ $cycle->set_plan_snapshot_id(
+ $this->copy_forward_or_insert(
+ $contract_id,
+ SnapshotStore::TYPE_PLAN,
+ $plan->get_selling_plan_id(),
+ $plan->to_payload(),
+ $plan->get_schema_version(),
+ null === $previous ? null : $previous->get_plan_snapshot_id()
+ )
+ );
+ }
+
+ $items = $cycle->get_items_snapshot();
+ if ( $items instanceof ItemsSnapshot && null === $cycle->get_items_snapshot_id() ) {
+ $cycle->set_items_snapshot_id(
+ $this->copy_forward_or_insert(
+ $contract_id,
+ SnapshotStore::TYPE_ITEMS,
+ null,
+ $items->to_payload(),
+ $items->get_schema_version(),
+ null === $previous ? null : $previous->get_items_snapshot_id()
+ )
+ );
+ }
+ }
+
+ /**
+ * Reuse the previous cycle's snapshot id when its payload + schema version match,
+ * else insert a fresh row.
+ *
+ * The match is a strict comparison against the JSON-decoded stored payload, so
+ * payload values must be JSON-round-trip-safe scalars: ints and strings (money as
+ * decimal strings, never PHP floats - a float may not decode back identical,
+ * defeating the reuse).
+ *
+ * @param int $contract_id Owning contract id.
+ * @param string $snapshot_type Snapshot type (`plan` | `items`).
+ * @param int|null $parent_id Weak link to the source, or null.
+ * @param array<int|string, mixed> $payload The new snapshot payload.
+ * @param int $schema_version Payload-format version.
+ * @param int|null $previous_snapshot_id The previous cycle's snapshot id, or null.
+ * @return int The reused or newly-inserted snapshot id.
+ */
+ private function copy_forward_or_insert( int $contract_id, string $snapshot_type, ?int $parent_id, array $payload, int $schema_version, ?int $previous_snapshot_id ): int {
+ if ( null !== $previous_snapshot_id ) {
+ $previous = $this->find_snapshot_payload( $previous_snapshot_id );
+ if ( null !== $previous && $previous['schema_version'] === $schema_version && $previous['payload'] === $payload ) {
+ return $previous_snapshot_id;
+ }
+ }
+
+ return $this->snapshots->insert( $contract_id, $snapshot_type, $parent_id, $payload, $schema_version );
+ }
+
+ /**
+ * Hydrate a cycle row, attaching typed snapshot value objects only for an in-flight
+ * (non-terminal) cycle. A settled record keeps its snapshot ids but skips the extra
+ * reads to decode their payloads.
+ *
+ * @param array<string, mixed> $row Cycle row.
+ * @return Cycle The hydrated cycle.
+ */
+ private function hydrate_cycle( array $row ): Cycle {
+ $cycle = Cycle::from_storage( $row );
+
+ if ( CycleStatus::is_terminal( $cycle->get_status()->get_value() ) ) {
+ return $cycle;
+ }
+
+ $plan = $this->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
+ if ( $plan instanceof PlanSnapshot ) {
+ $cycle->set_plan_snapshot( $plan );
+ }
+
+ $items = $this->find_items_snapshot( $cycle->get_items_snapshot_id() );
+ if ( $items instanceof ItemsSnapshot ) {
+ $cycle->set_items_snapshot( $items );
+ }
+
+ return $cycle;
+ }
+
+ /**
+ * Insert a cycle row and stamp the generated id back onto the entity.
+ *
+ * @param Cycle $cycle Cycle to insert. Carries its contract id and kind.
+ * @throws \RuntimeException If the cycle insert fails.
+ */
+ private function insert_cycle( Cycle $cycle ): void {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES ),
+ array_merge(
+ $cycle->to_storage(),
+ array(
+ 'date_created_gmt' => $now,
+ 'date_updated_gmt' => $now,
+ )
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( 'Failed to insert cycle.' );
+ }
+
+ $cycle->set_id( (int) $wpdb->insert_id );
+ }
+
+ /**
+ * Decode a stored plan snapshot row into a typed value object.
+ *
+ * @param int|null $snapshot_id Snapshot row id, or null.
+ * @return PlanSnapshot|null The decoded value object, or null.
+ */
+ private function find_plan_snapshot( ?int $snapshot_id ): ?PlanSnapshot {
+ $decoded = $this->find_snapshot_payload( $snapshot_id );
+ if ( null === $decoded ) {
+ return null;
+ }
+
+ return PlanSnapshot::from_payload( self::as_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
+ }
+
+ /**
+ * Decode a stored items snapshot row into a typed value object.
+ *
+ * @param int|null $snapshot_id Snapshot row id, or null.
+ * @return ItemsSnapshot|null The decoded value object, or null.
+ */
+ private function find_items_snapshot( ?int $snapshot_id ): ?ItemsSnapshot {
+ $decoded = $this->find_snapshot_payload( $snapshot_id );
+ if ( null === $decoded ) {
+ return null;
+ }
+
+ return ItemsSnapshot::from_payload( self::as_item_rows( $decoded['payload'] ), $decoded['schema_version'] );
+ }
+
+ /**
+ * Read and JSON-decode a snapshot row's payload and schema version.
+ *
+ * @param int|null $snapshot_id Snapshot row id, or null.
+ * @return array{payload: array<int|string, mixed>, schema_version: int}|null
+ */
+ private function find_snapshot_payload( ?int $snapshot_id ): ?array {
+ global $wpdb;
+
+ if ( null === $snapshot_id ) {
+ return null;
+ }
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT payload, schema_version FROM {$table} WHERE id = %d", $snapshot_id ), ARRAY_A );
+
+ if ( null === $row ) {
+ return null;
+ }
+
+ $payload = json_decode( (string) $row['payload'], true );
+
+ return array(
+ 'payload' => is_array( $payload ) ? $payload : array(),
+ 'schema_version' => (int) $row['schema_version'],
+ );
+ }
+
+ /**
+ * Re-key a decoded payload as a string-keyed map. A no-op at runtime (decoded JSON
+ * object keys are already strings); it recovers the string-keyed type that
+ * json_decode erases to `array<int|string, mixed>`.
+ *
+ * @param array<int|string, mixed> $payload Decoded payload.
+ * @return array<string, mixed>
+ */
+ private static function as_string_keyed( array $payload ): array {
+ $result = array();
+ foreach ( $payload as $key => $value ) {
+ $result[ (string) $key ] = $value;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Shape a decoded payload as an ordered list of item rows: each array element is
+ * re-keyed as a string-keyed row, non-array elements skipped. Recovers the value
+ * object's modelled shape without trusting the erased JSON types.
+ *
+ * @param array<int|string, mixed> $payload Decoded payload.
+ * @return array<int, array<string, mixed>>
+ */
+ private static function as_item_rows( array $payload ): array {
+ $rows = array();
+ foreach ( $payload as $row ) {
+ if ( is_array( $row ) ) {
+ $rows[] = self::as_string_keyed( $row );
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Read the contract row by id.
+ *
+ * @param int $id Contract id.
+ * @return array<string, mixed>|null
+ */
+ private function find_contract_row( int $id ): ?array {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+ return null === $row ? null : $row;
+ }
+
+ /**
+ * Update the contract row in place (no child rows).
+ *
+ * @param Contract $contract Contract whose row to update. Must have an id.
+ * @throws \RuntimeException If the update fails.
+ */
+ private function update_contract_row( Contract $contract ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $updated = $wpdb->update(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
+ array_merge(
+ $contract->to_storage(),
+ array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) )
+ ),
+ array( 'id' => (int) $contract->get_id() )
+ );
+
+ if ( false === $updated ) {
+ throw new \RuntimeException( 'Failed to update contract.' );
+ }
+ }
+
+ /**
+ * 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 the parent is gone - and a child reconcile
+ * would then write orphan rows (no FK enforces the relation). This narrows but does
+ * not fully close the race; the complete fix (a transaction with SELECT ... FOR UPDATE)
+ * is the seam a later transaction-handling change wraps.
+ *
+ * @param int $id Contract id.
+ * @throws \RuntimeException If the contract row no longer exists.
+ */
+ private function assert_contract_exists( int $id ): void {
+ if ( ! $this->exists( $id ) ) {
+ throw new \RuntimeException(
+ esc_html( sprintf( 'Cannot update contract %d: the contract row no longer exists (stale id or concurrent delete).', $id ) )
+ );
+ }
+ }
+
+ /**
+ * Reconcile a contract's items, addresses, and meta rows only when they differ.
+ *
+ * Each child set is compared via a normalized signature both the loaded rows and
+ * the entity's arrays are projected through, so MySQL's column coercion (DECIMAL
+ * padding, INT-as-string) applies to both sides and a no-op round-trip compares
+ * equal. Only a changed set is rewritten (delete-then-reinsert for that one table).
+ *
+ * @param Contract $contract Contract whose children to reconcile. Must have an id.
+ */
+ private function sync_children( Contract $contract ): void {
+ $id = (int) $contract->get_id();
+
+ if ( $this->items_signature( $this->find_items( $id ) ) !== $this->items_signature( $contract->get_items() ) ) {
+ $this->replace_items( $id, $contract->get_items() );
+ }
+
+ if ( $this->addresses_signature( $this->find_addresses( $id ) ) !== $this->addresses_signature( $contract->get_addresses() ) ) {
+ $this->replace_addresses( $id, $contract->get_addresses() );
+ }
+
+ if ( $this->meta_signature( $this->find_meta( $id ) ) !== $this->meta_signature( $contract->get_meta() ) ) {
+ $this->replace_meta( $id, $contract->get_meta() );
+ }
+ }
+
+ /**
+ * Delete-then-reinsert a contract's item rows.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<int, array<string, mixed>> $items Item rows.
+ */
+ private function replace_items( int $contract_id, array $items ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ), array( 'contract_id' => $contract_id ) );
+ $this->insert_items( $contract_id, $items );
+ }
+
+ /**
+ * Delete-then-reinsert a contract's address rows.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
+ */
+ private function replace_addresses( int $contract_id, array $addresses ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), array( 'contract_id' => $contract_id ) );
+ $this->insert_addresses( $contract_id, $addresses );
+ }
+
+ /**
+ * Delete-then-reinsert a contract's meta rows.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, string> $meta Meta as key => value.
+ */
+ private function replace_meta( int $contract_id, array $meta ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) );
+ $this->insert_meta( $contract_id, $meta );
+ }
+
/**
* Insert line items for a contract.
*
@@ -257,7 +803,7 @@ final class ContractRepository {
);
foreach ( self::ADDRESS_COLUMNS as $column ) {
- $record[ $column ] = self::coerce_nullable_string( $address[ $column ] ?? null );
+ $record[ $column ] = isset( $address[ $column ] ) ? self::coerce_string( $address[ $column ] ) : null;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -275,8 +821,8 @@ final class ContractRepository {
global $wpdb;
foreach ( $meta as $key => $value ) {
- // These are the engine's own contract-meta columns, not post/order
- // meta; the slow-meta-query heuristic does not apply.
+ // The engine's own contract-meta columns, not post/order meta; the
+ // slow-meta-query heuristic does not apply.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
$wpdb->insert(
SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
@@ -303,14 +849,7 @@ final class ContractRepository {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d ORDER BY id ASC", $contract_id ), ARRAY_A );
- $items = array();
- foreach ( (array) $rows as $row ) {
- if ( is_array( $row ) ) {
- $items[] = $row;
- }
- }
-
- return $items;
+ return self::as_item_rows( is_array( $rows ) ? $rows : array() );
}
/**
@@ -328,9 +867,9 @@ final class ContractRepository {
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );
$by_type = array();
- foreach ( (array) $rows as $row ) {
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
- $by_type[ self::coerce_string( $row['address_type'] ?? null ) ] = $row;
+ $by_type[ self::coerce_string( $row['address_type'] ?? null ) ] = self::as_string_keyed( $row );
}
}
@@ -348,13 +887,13 @@ final class ContractRepository {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
- // These are the engine's own contract-meta columns, not post/order meta;
- // the slow-meta-query heuristic does not apply.
+ // The engine's own contract-meta columns, not post/order meta; the
+ // slow-meta-query heuristic does not apply.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );
$meta = array();
- foreach ( (array) $rows as $row ) {
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
$meta[ self::coerce_string( $row['meta_key'] ?? null ) ] = self::coerce_string( $row['meta_value'] ?? null );
}
@@ -362,4 +901,100 @@ final class ContractRepository {
return $meta;
}
+
+ /**
+ * A change-detection signature for an item set: each item projected to the
+ * comparable columns, with money / count fields coerced to the fixed shape MySQL
+ * stores (DECIMAL scale, INT-as-string) so a no-op round-trip compares equal in
+ * {@see self::sync_children()}. The id, `contract_id`, and column order are ignored.
+ *
+ * @param array<int, array<string, mixed>> $items Item arrays (loaded rows or entity items).
+ * @return array<int, array<string, string|null>> Per-item comparable projection.
+ */
+ private function items_signature( array $items ): array {
+ $signature = array();
+
+ foreach ( $items as $item ) {
+ $signature[] = array(
+ 'item_name' => self::coerce_string( $item['item_name'] ?? null ),
+ 'item_type' => self::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+ 'product_id' => isset( $item['product_id'] ) ? (string) self::coerce_int( $item['product_id'] ) : null,
+ 'variation_id' => isset( $item['variation_id'] ) ? (string) self::coerce_int( $item['variation_id'] ) : null,
+ 'quantity' => number_format( self::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
+ 'subtotal' => number_format( self::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
+ 'total' => number_format( self::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
+ 'taxes' => $this->taxes_signature( $item['taxes'] ?? null ),
+ );
+ }
+
+ return $signature;
+ }
+
+ /**
+ * A change-detection signature for an address set, keyed by type.
+ *
+ * Projects each address to the persisted columns with the same string coercion
+ * the load path returns, so an unchanged set compares equal in
+ * {@see self::sync_children()}.
+ *
+ * @param array<string, array<string, mixed>> $addresses Address arrays keyed by type.
+ * @return array<string, array<string, string|null>> Per-type comparable projection.
+ */
+ private function addresses_signature( array $addresses ): array {
+ $signature = array();
+
+ foreach ( $addresses as $type => $address ) {
+ $record = array();
+ foreach ( self::ADDRESS_COLUMNS as $column ) {
+ $value = isset( $address[ $column ] ) ? self::coerce_string( $address[ $column ] ) : '';
+ $record[ $column ] = '' !== $value ? $value : null;
+ }
+
+ $signature[ (string) $type ] = $record;
+ }
+
+ ksort( $signature );
+
+ return $signature;
+ }
+
+ /**
+ * A change-detection signature for a meta set.
+ *
+ * @param array<string, string> $meta Meta as key => value.
+ * @return array<string, string> Comparable projection (key-sorted).
+ */
+ private function meta_signature( array $meta ): array {
+ $signature = array();
+
+ foreach ( $meta as $key => $value ) {
+ $signature[ (string) $key ] = (string) $value;
+ }
+
+ ksort( $signature );
+
+ return $signature;
+ }
+
+ /**
+ * Normalize a taxes value to canonical JSON (or null), so a loaded JSON string and
+ * the entity's decoded array compare equal regardless of which side it came from.
+ *
+ * @param mixed $taxes A JSON string (loaded) or an array (entity), or null.
+ * @return string|null Canonical JSON, or null when there is nothing to compare.
+ */
+ private function taxes_signature( $taxes ): ?string {
+ if ( null === $taxes ) {
+ return null;
+ }
+
+ $decoded = is_string( $taxes ) ? json_decode( $taxes, true ) : $taxes;
+ if ( ! is_array( $decoded ) ) {
+ return null;
+ }
+
+ $encoded = wp_json_encode( $decoded );
+
+ return false === $encoded ? null : $encoded;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
index 47598c51d55..878140b7af9 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -1,19 +1,14 @@
<?php
/**
- * SchemaInstaller - owns the engine's baseline database tables.
+ * Owns the engine's baseline database tables (plan, contract, and cycle tables).
+ * Mirrors the order/HPOS conventions: BIGINT UNSIGNED ids, `*_gmt` datetime
+ * columns, JSON columns for policy bundles, no foreign-key constraints. A chain is
+ * not a stored table: it is the pair `(contract_id, kind)` on the cycle rows.
*
- * Creates and drops the plan tables (`wc_selling_plan_groups`,
- * `wc_selling_plans`) and the contract tables (`wc_subscription_contracts`,
- * `wc_subscription_contract_items`, `wc_subscription_contract_addresses`,
- * `wc_subscription_contract_meta`). Mirrors the order/HPOS conventions:
- * BIGINT UNSIGNED ids, `*_gmt` datetime columns, JSON columns for policy
- * bundles, no foreign-key constraints.
- *
- * Schema is additive-only: columns shipped here are permanent.
- *
- * The engine is bundled rather than independently activated, so install runs
- * through {@see self::maybe_install()} (a version-gated check on boot), not a
- * plugin activation hook.
+ * Pre-freeze, tables are private and mutable: schema changes ship via a `VERSION`
+ * bump that re-runs dbDelta, not migrations. Install runs through
+ * {@see self::maybe_install()} (a version-gated check on boot), as the engine is
+ * bundled rather than independently activated.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
*/
@@ -33,10 +28,17 @@ final class SchemaInstaller {
* Schema version. Bump when the CREATE TABLE statements change so the
* version-gated install runs dbDelta again.
*
- * 1.0.0 - baseline plan and contract tables, including the nullable `extension_slug`
- * column on plans and contracts.
+ * 1.0.0 - baseline plan and contract tables.
+ * 2.0.0 - cycle-chain model: contract as live source of truth (schedule, snapshot
+ * references, totals, stamps); immutable cycle records keyed on
+ * `(contract_id, kind)`; per-contract snapshots deduped by copy-forward.
+ *
+ * Pre-freeze, tables are recreated rather than migrated. dbDelta adds columns but
+ * does not change an existing column's nullability or drop unused ones, so a dev box
+ * on an earlier schema must drop and recreate the tables (and clear VERSION_OPTION)
+ * to pick up such changes - in-place ALTERs and backfills arrive with the freeze.
*/
- const VERSION = '1.0.0';
+ const VERSION = '2.0.0';
/**
* Option key tracking the installed schema version.
@@ -52,6 +54,8 @@ final class SchemaInstaller {
const TABLE_CONTRACT_ITEMS = 'contract_items';
const TABLE_CONTRACT_ADDRESSES = 'contract_addresses';
const TABLE_CONTRACT_META = 'contract_meta';
+ const TABLE_CYCLES = 'cycles';
+ const TABLE_SNAPSHOTS = 'snapshots';
/**
* Resolve a logical identifier to its prefixed table name.
@@ -126,10 +130,9 @@ final class SchemaInstaller {
}
/**
- * Map of logical => prefixed table names, keyed by TABLE_* constants.
- *
- * Contract tables use the `wc_subscription_*` prefix (what the data
- * represents), while the namespace boundary is about code ownership.
+ * Map of logical => prefixed table names, keyed by TABLE_* constants. Contract
+ * tables use the `wc_subscription_*` prefix (what the data represents), independent
+ * of the code-ownership namespace boundary.
*
* @param string $prefix Usually `$wpdb->prefix`.
* @return array<string, string>
@@ -142,16 +145,17 @@ final class SchemaInstaller {
self::TABLE_CONTRACT_ITEMS => $prefix . 'wc_subscription_contract_items',
self::TABLE_CONTRACT_ADDRESSES => $prefix . 'wc_subscription_contract_addresses',
self::TABLE_CONTRACT_META => $prefix . 'wc_subscription_contract_meta',
+ self::TABLE_CYCLES => $prefix . 'wc_subscription_cycles',
+ self::TABLE_SNAPSHOTS => $prefix . 'wc_subscription_snapshots',
);
}
/**
* CREATE TABLE statements, formatted for dbDelta.
*
- * The dbDelta function is fussy: each column on its own line, two spaces
- * between name and type, `KEY` (not `INDEX`), no trailing comma before
- * PRIMARY KEY. Do not reformat these without re-testing dbDelta diffing - it
- * parses with regex.
+ * Formatting is fragile: dbDelta parses with regex (each column on its own line,
+ * two spaces between name and type, `KEY` not `INDEX`, no trailing comma before
+ * PRIMARY KEY). Do not reformat these without re-testing dbDelta diffing.
*
* @param array<string, string> $names Map of logical => prefixed table names.
* @param string $collate Charset/collate clause from $wpdb.
@@ -164,10 +168,12 @@ final class SchemaInstaller {
$contract_items = $names[ self::TABLE_CONTRACT_ITEMS ];
$contract_addresses = $names[ self::TABLE_CONTRACT_ADDRESSES ];
$contract_meta = $names[ self::TABLE_CONTRACT_META ];
+ $cycles = $names[ self::TABLE_CYCLES ];
+ $snapshots = $names[ self::TABLE_SNAPSHOTS ];
- // `merchant_code` is UNIQUE (not just KEY) for DB-enforced idempotency on
- // consumer-supplied codes. NULL values are allowed and treated as distinct,
- // so consumers that do not use merchant codes are unaffected.
+ // `merchant_code` is UNIQUE (not KEY) for DB-enforced idempotency on
+ // consumer-supplied codes; NULLs are treated as distinct, so consumers that do
+ // not use merchant codes are unaffected.
$plan_groups_sql = "CREATE TABLE {$plan_groups} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
@@ -181,9 +187,9 @@ final class SchemaInstaller {
KEY app_id (app_id)
) {$collate};";
- // `extension_slug` records the registered slug of the extension that created the
- // plan. Nullable while owner identifier/registration semantics are still
- // open; tightened additively once decided.
+ // `extension_slug` records the creating extension's registered slug. Nullable
+ // while owner identifier/registration semantics are still open; tightened
+ // additively once decided.
$plans_sql = "CREATE TABLE {$plans} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
group_id BIGINT UNSIGNED NOT NULL,
@@ -204,33 +210,36 @@ final class SchemaInstaller {
KEY extension_slug (extension_slug)
) {$collate};";
- // `currency` is first-class (forward-compat for multi-currency recurring;
- // today always the store base currency). `schedule_source` distinguishes
- // contracts whose renewals this engine owns from gateway-owned schedules.
- // `extension_slug` mirrors the plans column. Totals follow the order PHP-property
- // naming rather than the HPOS storage-column names.
+ // The contract row is the live source of truth: the totals and stamps are live
+ // values, not caches of cycles. The `due` index keys the renewal scan off
+ // `next_payment_gmt`. `origin_order_id` is NULLABLE (a manual/admin contract has
+ // no origin order). There is no generic `cycle_count` - counters are per-chain,
+ // derived as `MAX(count)` over `(contract_id, kind)`. `currency` is first-class
+ // (forward-compat for multi-currency recurring; today the store base currency).
+ // `schedule_source` distinguishes engine-owned renewals from gateway-owned schedules.
$contracts_sql = "CREATE TABLE {$contracts} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
status VARCHAR(20) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
currency CHAR(3) NOT NULL,
selling_plan_id BIGINT UNSIGNED NOT NULL,
- origin_order_id BIGINT UNSIGNED NOT NULL,
+ origin_order_id BIGINT UNSIGNED NULL,
extension_slug VARCHAR(64) NULL,
payment_method VARCHAR(100) NULL,
payment_method_title VARCHAR(200) NULL,
payment_token_id BIGINT UNSIGNED NULL,
+ start_gmt DATETIME NOT NULL,
+ next_payment_gmt DATETIME NULL,
+ plan_snapshot_id BIGINT UNSIGNED NULL,
+ items_snapshot_id BIGINT UNSIGNED NULL,
billing_total DECIMAL(26,8) NOT NULL DEFAULT 0,
discount_total DECIMAL(26,8) NOT NULL DEFAULT 0,
shipping_total DECIMAL(26,8) NOT NULL DEFAULT 0,
tax_total DECIMAL(26,8) NOT NULL DEFAULT 0,
- start_gmt DATETIME NOT NULL,
- next_payment_gmt DATETIME NULL,
last_payment_gmt DATETIME NULL,
last_attempt_gmt DATETIME NULL,
trial_end_gmt DATETIME NULL,
end_gmt DATETIME NULL,
- cycle_count INT UNSIGNED NOT NULL DEFAULT 0,
schedule_source VARCHAR(20) NOT NULL DEFAULT 'primitive',
date_created_gmt DATETIME NOT NULL,
date_updated_gmt DATETIME NOT NULL,
@@ -257,7 +266,7 @@ final class SchemaInstaller {
) {$collate};";
// One billing + one shipping address per contract: composite PK on
- // (contract_id, address_type). Mirrors the order-addresses column shape.
+ // (contract_id, address_type).
$contract_addresses_sql = "CREATE TABLE {$contract_addresses} (
contract_id BIGINT UNSIGNED NOT NULL,
address_type VARCHAR(20) NOT NULL,
@@ -284,6 +293,59 @@ final class SchemaInstaller {
KEY contract_key (contract_id, meta_key(100))
) {$collate};";
+ // Immutable billing records. A chain is the pair `(contract_id, kind)` - there is
+ // no chains table - so cycles carry both directly. `chain_seq` (UNIQUE) keeps a
+ // chain from holding two cycles at one position; `chain_count` (UNIQUE) is the
+ // per-charge idempotency anchor, with `count` nullable so non-counting cycles
+ // (e.g. future trial periods) coexist freely under MySQL's NULL-distinct rule.
+ // The `due` index keys the dispatcher's due-scan in (kind, status, starts_at_gmt)
+ // order, since billing-in-advance fires at `starts_at_gmt`. `order_id` is non-1:1
+ // (an aggregate order may serve many cycles); `contract_kind` serves targeted
+ // per-chain reads (MAX(count), head).
+ $cycles_sql = "CREATE TABLE {$cycles} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ contract_id BIGINT UNSIGNED NOT NULL,
+ kind VARCHAR(20) NOT NULL DEFAULT 'billing',
+ sequence_no INT UNSIGNED NOT NULL,
+ count INT UNSIGNED NULL,
+ status VARCHAR(20) NOT NULL,
+ reason TEXT NULL,
+ starts_at_gmt DATETIME NOT NULL,
+ ends_at_gmt DATETIME NOT NULL,
+ expected_total DECIMAL(26,8) NOT NULL DEFAULT 0,
+ currency CHAR(3) NOT NULL,
+ plan_snapshot_id BIGINT UNSIGNED NULL,
+ items_snapshot_id BIGINT UNSIGNED NULL,
+ order_id BIGINT UNSIGNED NULL,
+ extension_slug VARCHAR(64) NULL,
+ date_created_gmt DATETIME NOT NULL,
+ date_updated_gmt DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY chain_seq (contract_id, kind, sequence_no),
+ UNIQUE KEY chain_count (contract_id, kind, count),
+ KEY due (kind, status, starts_at_gmt),
+ KEY order_id (order_id),
+ KEY contract_kind (contract_id, kind)
+) {$collate};";
+
+ // Per-contract typed snapshot payloads, deduped by copy-forward (no content
+ // hash). `parent_id` is the weak link back to the source (the plan a plan
+ // snapshot was taken from). `schema_version` is the payload-FORMAT version a
+ // reader parses/upcasts by, not the plan's content version. LONGTEXT payload for
+ // the MySQL 5.6 floor (no JSON column type).
+ $snapshots_sql = "CREATE TABLE {$snapshots} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ contract_id BIGINT UNSIGNED NOT NULL,
+ snapshot_type VARCHAR(20) NOT NULL,
+ parent_id BIGINT UNSIGNED NULL,
+ schema_version SMALLINT UNSIGNED NOT NULL DEFAULT 1,
+ payload LONGTEXT NOT NULL,
+ date_created_gmt DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY contract_type (contract_id, snapshot_type),
+ KEY parent (parent_id)
+) {$collate};";
+
return array(
$plan_groups_sql,
$plans_sql,
@@ -291,6 +353,8 @@ final class SchemaInstaller {
$contract_items_sql,
$contract_addresses_sql,
$contract_meta_sql,
+ $cycles_sql,
+ $snapshots_sql,
);
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SnapshotStore.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SnapshotStore.php
new file mode 100644
index 00000000000..0e52a58cf5e
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SnapshotStore.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Per-contract typed storage for cycle snapshot payloads. Does the one thing
+ * copy-forward dedup leaves it - insert a typed snapshot row and return its id;
+ * deciding when to reuse an existing id is the caller's job (repository / factory),
+ * not this store's. There is no content hash or uniqueness constraint: copy-forward,
+ * not a UNIQUE index, is what keeps identical consecutive snapshots from duplicating.
+ *
+ * A row is per contract and typed (`contract_id`, `snapshot_type`, a weak `parent_id`
+ * link to the source, and a `schema_version` that is the payload FORMAT version, NOT
+ * the plan's content version). Integration layer: JSON encoding happens here, never
+ * in `Core\`. No foreign keys (MySQL 5.6 floor).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Per-contract typed snapshot store.
+ */
+final class SnapshotStore {
+
+ const TYPE_PLAN = 'plan';
+ const TYPE_ITEMS = 'items';
+
+ /**
+ * Insert a typed snapshot row and return its id. The payload (the value object's
+ * WP-free serialized form) is JSON-encoded here into the LONGTEXT column. No dedup
+ * at insert time - copy-forward reuse is the caller's job.
+ *
+ * @param int $contract_id Owning contract id.
+ * @param string $snapshot_type Snapshot type (`plan` | `items`).
+ * @param int|null $parent_id Weak link to the source (the plan id for a plan snapshot; null for items).
+ * @param array<int|string, mixed> $payload Snapshot payload to serialize.
+ * @param int $schema_version Payload-format version.
+ * @return int The inserted snapshot row id.
+ * @throws \RuntimeException If the payload cannot be encoded or the insert fails.
+ */
+ public function insert( int $contract_id, string $snapshot_type, ?int $parent_id, array $payload, int $schema_version ): int {
+ global $wpdb;
+
+ $json = wp_json_encode( $payload );
+ if ( false === $json ) {
+ throw new \RuntimeException( 'Failed to JSON-encode snapshot payload.' );
+ }
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ $table,
+ array(
+ 'contract_id' => $contract_id,
+ 'snapshot_type' => $snapshot_type,
+ 'parent_id' => $parent_id,
+ 'schema_version' => $schema_version,
+ 'payload' => $json,
+ 'date_created_gmt' => gmdate( 'Y-m-d H:i:s' ),
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( 'Failed to insert snapshot.' );
+ }
+
+ return (int) $wpdb->insert_id;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
index 1fed245e80c..1bc78f3fadf 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
@@ -13,6 +13,8 @@ use EngineIntegrationTestCase;
use WC_Order;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanGroup;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
@@ -71,6 +73,9 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
return $order;
}
+ /**
+ * @testdox create_from_order persists and links a lean contract.
+ */
public function test_create_from_order_persists_and_links_contract(): void {
$order = $this->make_order();
$plan = $this->make_plan();
@@ -97,6 +102,71 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$this->assertSame( OrderLinkage::RELATION_PARENT, $tagged_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
}
+ /**
+ * @testdox create_from_order builds cycle 1 as the paid origin period.
+ */
+ public function test_create_from_order_builds_cycle_one_as_the_origin_period(): 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() );
+
+ // The contract's next-bill cache is the first renewal, one cadence out.
+ $this->assertSame( '2026-02-15 00:00:00', $contract->get_next_payment_gmt() );
+
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $repo = new ContractRepository();
+ $cycle = $repo->find_current_cycle( $contract_id );
+
+ $this->assertInstanceOf( Cycle::class, $cycle );
+ $this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+ $this->assertSame( 1, $cycle->get_sequence_no() );
+
+ // Cycle 1 is the origin period: count 1, linked to the origin order, billed.
+ $this->assertSame( 1, $cycle->get_count() );
+ $this->assertSame( $order->get_id(), $cycle->get_order_id() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertSame( 'lite', $cycle->get_extension_slug() );
+
+ // Its period runs from the paid time to the first renewal date.
+ $this->assertSame( '2026-01-15 00:00:00', $cycle->get_starts_at_gmt() );
+ $this->assertSame( '2026-02-15 00:00:00', $cycle->get_ends_at_gmt() );
+ $this->assertSame( '19.99000000', $cycle->get_expected_total() );
+
+ // It carries the plan + items snapshots, stored on the repository create path.
+ $this->assertNotNull( $cycle->get_plan_snapshot_id() );
+ $this->assertNotNull( $cycle->get_items_snapshot_id() );
+
+ // The contract is the live source of truth: it records the SAME snapshot refs
+ // as cycle 1 and seeds its live billing total from the order.
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertSame( $cycle->get_plan_snapshot_id(), $reloaded->get_plan_snapshot_id() );
+ $this->assertSame( $cycle->get_items_snapshot_id(), $reloaded->get_items_snapshot_id() );
+ $this->assertSame( '19.99000000', $reloaded->get_billing_total() );
+ }
+
+ /**
+ * @testdox The origin cycle is reachable by the origin order id.
+ */
+ public function test_origin_cycle_is_reachable_by_order_id(): void {
+ $order = $this->make_order();
+ $order->save();
+
+ $contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
+
+ $cycles = ( new ContractRepository() )->find_cycles_by_order_id( $order->get_id() );
+ $this->assertCount( 1, $cycles );
+ $this->assertSame( $contract->get_id(), $cycles[0]->get_contract_id() );
+ $this->assertSame( 1, $cycles[0]->get_count() );
+ }
+
+ /**
+ * @testdox The first renewal date follows the billing cadence.
+ */
public function test_first_renewal_date_follows_billing_cadence(): void {
$order = $this->make_order();
$order->set_date_paid( '2026-01-15 00:00:00' );
@@ -104,10 +174,12 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$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() );
}
+ /**
+ * @testdox A native trial delays the first renewal date.
+ */
public function test_native_trial_delays_first_renewal(): void {
$order = $this->make_order();
$order->set_date_paid( '2026-01-15 00:00:00' );
@@ -125,8 +197,19 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
// First bill is the trial end, not one month out.
$this->assertSame( '2026-01-29 00:00:00', $contract->get_next_payment_gmt() );
+
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ // Cycle 1's period end matches that first renewal date.
+ $cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $cycle );
+ $this->assertSame( '2026-01-29 00:00:00', $cycle->get_ends_at_gmt() );
}
+ /**
+ * @testdox Overrides take precedence over order-derived values.
+ */
public function test_overrides_take_precedence(): void {
$order = $this->make_order();
$plan = $this->make_plan();
@@ -140,10 +223,21 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
)
);
- $this->assertSame( '49.00', $contract->get_billing_total() );
+ // next_payment_gmt override sets both the cache and cycle 1's period end.
$this->assertSame( '2026-12-01 00:00:00', $contract->get_next_payment_gmt() );
+
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $cycle = ( new ContractRepository() )->find_current_cycle( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $cycle );
+ $this->assertSame( '49.00000000', $cycle->get_expected_total() );
+ $this->assertSame( '2026-12-01 00:00:00', $cycle->get_ends_at_gmt() );
}
+ /**
+ * @testdox An unsaved plan is rejected.
+ */
public function test_unsaved_plan_is_rejected(): void {
$order = $this->make_order();
$plan = Plan::create(
@@ -159,6 +253,9 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
( new ContractFactory() )->create_from_order( $order, $plan );
}
+ /**
+ * @testdox An unsaved order is rejected.
+ */
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.
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
index 393c8389a1f..622c64f0bde 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalEngineTest.php
@@ -68,39 +68,11 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
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
- )
- );
- $this->assertIsArray( $orders );
-
- 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 {
+ private function make_contract( int $plan_id, int $origin_order_id ): Contract {
+ // A lean contract row (no cycles). The renewal-advancement tests that
+ // exercise the money-path are skipped until the dispatcher slice; when they
+ // are reactivated they will append a billing cycle (with an expected_total)
+ // so the renewal amount resolves off the current cycle.
$contract = Contract::create(
array(
'customer_id' => 1,
@@ -108,7 +80,6 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'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',
)
@@ -118,11 +89,13 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
return $contract;
}
+ /**
+ * @testdox schedule is gated on the gateway's recurring capability.
+ */
public function test_schedule_is_gated_on_recurring_capability(): void {
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
-
+ $plan_id = $this->make_plan();
+ $order = $this->make_origin_order();
+ $contract = $this->make_contract( $plan_id, $order->get_id() );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
@@ -138,13 +111,15 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
}
+ /**
+ * @testdox schedule replaces any existing pending row (one row per contract).
+ */
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() );
-
+ $plan_id = $this->make_plan();
+ $order = $this->make_origin_order();
+ $contract = $this->make_contract( $plan_id, $order->get_id() );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
@@ -164,125 +139,123 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertCount( 1, $pending );
}
- public function test_process_due_creates_renewal_and_advances(): void {
+ /**
+ * @testdox process_due creates a renewal order tagged for the next chargeable number.
+ */
+ public function test_process_due_creates_renewal_order(): void {
GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
- $plan_id = $this->make_plan();
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
-
+ $plan_id = $this->make_plan();
+ $order = $this->make_origin_order();
+ $contract = $this->make_contract( $plan_id, $order->get_id() );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- $engine = new RenewalEngine();
- $renewal_order = $engine->process_due( $contract_id );
+ $renewal_order = ( new RenewalEngine() )->process_due( $contract_id );
+ // Order creation is wired; advancing the chain is the dispatcher slice, so
+ // only order creation + tagging is asserted here.
$this->assertInstanceOf( WC_Order::class, $renewal_order );
$this->assertSame( (string) $contract_id, $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
$this->assertSame( OrderLinkage::RELATION_RENEWAL, $renewal_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
- $this->assertSame( '19.99', $renewal_order->get_total() );
-
- // Contract advanced: cycle_count incremented, next bill date moved one month.
- $reloaded = ( new ContractRepository() )->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $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_id ) );
+ // A lean contract has no counting cycle yet, so the next chargeable number is 1.
+ $this->assertSame( '1', $renewal_order->get_meta( '_subscription_renewal_cycle' ) );
+ $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
}
+ /**
+ * @testdox process_due is idempotent: a retried due action creates no second order.
+ */
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();
-
+ $plan_id = $this->make_plan();
+ $order = $this->make_origin_order();
+ $contract = $this->make_contract( $plan_id, $order->get_id() );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
+ $engine = new RenewalEngine();
- // First fire creates the cycle-1 renewal and advances to cycle 1.
$first = $engine->process_due( $contract_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_id );
- $this->assertInstanceOf( Contract::class, $rewound );
- $rewound->set_cycle_count( 0 );
- $rewound->set_status( ContractStatus::ACTIVE );
- $rewound->set_next_payment_gmt( '2026-02-15 00:00:00' );
- $repo->update( $rewound );
-
+ // A retried due action for the same chargeable number is suppressed.
$retry = $engine->process_due( $contract_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_id, 1 ) );
-
- $reloaded = $repo->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( 0, $reloaded->get_cycle_count() );
}
+ /**
+ * @testdox process_due expires the contract when it hits max cycles.
+ */
public function test_process_due_expires_contract_at_max_cycles(): void {
- GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
+ $this->markTestSkipped( 'Max-cycle expiry lands with the dispatcher.' );
+ }
- $plan_id = $this->make_plan( 1 );
- $order = $this->make_origin_order();
- $contract = $this->make_contract( $plan_id, $order->get_id() );
+ /**
+ * @testdox process_due skips a non-active contract and creates no renewal order.
+ */
+ 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_id = $contract->get_id();
$this->assertNotNull( $contract_id );
+ $contract->set_status( ContractStatus::ON_HOLD );
+ ( new ContractRepository() )->update( $contract );
- $engine = new RenewalEngine();
- $engine->process_due( $contract_id );
+ $this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
- $reloaded = ( new ContractRepository() )->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $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_id ) );
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
}
- public function test_process_due_skips_non_active_contract(): void {
+ /**
+ * @testdox process_due skips a gateway-scheduled contract and creates no renewal order.
+ */
+ public function test_process_due_skips_gateway_scheduled_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 );
-
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
+ $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,
+ 'start_gmt' => '2026-01-15 00:00:00',
+ 'next_payment_gmt' => '2026-02-15 00:00:00',
+ 'schedule_source' => Contract::SCHEDULE_SOURCE_GATEWAY,
+ )
+ );
+ $contract_id = ( new ContractRepository() )->insert( $contract );
+ // Active, but the gateway owns the schedule: the primitive path bails.
$this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
-
- $reloaded = ( new ContractRepository() )->find( $contract_id );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( 0, $reloaded->get_cycle_count() );
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
}
+ /**
+ * @testdox process_due skips an unknown contract.
+ */
public function test_process_due_skips_unknown_contract(): void {
$this->assertNull( ( new RenewalEngine() )->process_due( 999999 ) );
}
+ /**
+ * @testdox cancel transitions the contract to cancelled and clears its pending row.
+ */
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() );
-
+ $plan_id = $this->make_plan();
+ $order = $this->make_origin_order();
+ $contract = $this->make_contract( $plan_id, $order->get_id() );
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
@@ -298,30 +271,60 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
+ /**
+ * @testdox A gateway-scheduled contract is not scheduled by the engine.
+ */
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(
+ $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 );
-
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
+ $contract_id = ( new ContractRepository() )->insert( $contract );
$this->assertFalse( ( new RenewalEngine() )->schedule( $contract ) );
$this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
+
+ /**
+ * Renewal orders tagged for a contract at a given chargeable number, narrowed
+ * in PHP (store-agnostic, like the engine's own idempotency check).
+ *
+ * @param int $contract_id Contract id.
+ * @param int $count Chargeable number.
+ * @return array<int, WC_Order>
+ */
+ private function renewal_orders_for_cycle( int $contract_id, int $count ): array {
+ $orders = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'type' => 'shop_order',
+ 'status' => 'any',
+ 'meta_key' => OrderLinkage::META_CONTRACT_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => (string) $contract_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ )
+ );
+
+ return array_values(
+ array_filter(
+ is_array( $orders ) ? $orders : array(),
+ static function ( $order ) use ( $count ) {
+ return $order instanceof WC_Order
+ && OrderLinkage::RELATION_RENEWAL === $order->get_meta( OrderLinkage::META_RELATION_TYPE )
+ && (string) $count === $order->get_meta( '_subscription_renewal_cycle' );
+ }
+ )
+ );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index 726bce0fec8..b8d61c22776 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
@@ -1,6 +1,6 @@
<?php
/**
- * Integration tests for ContractRepository.
+ * Integration tests for the lean ContractRepository and its targeted cycle access.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -12,13 +12,30 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati
use EngineIntegrationTestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
*/
class ContractRepositoryTest extends EngineIntegrationTestCase {
+ /**
+ * The System Under Test.
+ *
+ * @var ContractRepository
+ */
+ private $sut;
+
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new ContractRepository();
+ }
+
private function make_contract(): Contract {
return Contract::create(
array(
@@ -30,9 +47,16 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'payment_method' => 'woocommerce_payments',
'payment_method_title' => 'Credit card',
'payment_token_id' => 55,
- 'billing_total' => '19.99',
'start_gmt' => '2026-06-15 00:00:00',
'next_payment_gmt' => '2026-07-15 00:00:00',
+ 'billing_total' => '19.99',
+ 'discount_total' => '1.00',
+ 'shipping_total' => '5.00',
+ 'tax_total' => '2.50',
+ 'last_payment_gmt' => '2026-06-15 00:00:00',
+ 'last_attempt_gmt' => '2026-06-15 00:00:00',
+ 'trial_end_gmt' => null,
+ 'end_gmt' => null,
'items' => array(
array(
'item_name' => 'Coffee bag',
@@ -63,13 +87,64 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
);
}
- public function test_contract_round_trips_with_children(): void {
- $repo = new ContractRepository();
+ /**
+ * Build a billing cycle for the given contract at a sequence/count.
+ *
+ * @param int $contract_id Contract id.
+ * @param int $sequence_no Position in the chain.
+ * @param int|null $count Chargeable count, or null for non-counting.
+ * @param string $starts_at Period start GMT string.
+ * @param string $ends_at Period end GMT string.
+ * @param PlanSnapshot|null $plan Plan snapshot, or null.
+ * @param ItemsSnapshot|null $items Items snapshot, or null.
+ * @param int|null $order_id Linked order id, or null.
+ */
+ private function make_cycle( int $contract_id, int $sequence_no, ?int $count, string $starts_at, string $ends_at, ?PlanSnapshot $plan = null, ?ItemsSnapshot $items = null, ?int $order_id = null ): Cycle {
+ return Cycle::create(
+ array(
+ 'contract_id' => $contract_id,
+ 'sequence_no' => $sequence_no,
+ 'count' => $count,
+ 'starts_at_gmt' => $starts_at,
+ 'ends_at_gmt' => $ends_at,
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'extension_slug' => 'lite',
+ 'order_id' => $order_id,
+ 'plan_snapshot' => $plan,
+ 'items_snapshot' => $items,
+ )
+ );
+ }
+
+ private function sample_plan_snapshot(): PlanSnapshot {
+ return PlanSnapshot::from_array(
+ array(
+ 'selling_plan_id' => 7,
+ 'cadence' => 'monthly',
+ )
+ );
+ }
+
+ private function sample_items_snapshot(): ItemsSnapshot {
+ return ItemsSnapshot::from_items(
+ array(
+ array(
+ 'product_id' => 200,
+ 'quantity' => 1,
+ ),
+ )
+ );
+ }
- $id = $repo->insert( $this->make_contract() );
+ /**
+ * @testdox A contract round-trips its live state, children, and config, no cycle graph.
+ */
+ public function test_contract_round_trips_its_live_state(): void {
+ $id = $this->sut->insert( $this->make_contract() );
$this->assertGreaterThan( 0, $id );
- $fetched = $repo->find( $id );
+ $fetched = $this->sut->find( $id );
$this->assertInstanceOf( Contract::class, $fetched );
$this->assertSame( $id, $fetched->get_id() );
@@ -77,32 +152,104 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( 'USD', $fetched->get_currency() );
$this->assertSame( 'lite', $fetched->get_extension_slug() );
$this->assertSame( ContractStatus::ACTIVE, $fetched->get_status() );
+ $this->assertSame( 1001, $fetched->get_origin_order_id() );
$this->assertSame( '2026-07-15 00:00:00', $fetched->get_next_payment_gmt() );
- // Payment instrument reference.
+ // The live config round-trips (totals normalized to the storage scale).
+ $this->assertSame( '19.99000000', $fetched->get_billing_total() );
+ $this->assertSame( '1.00000000', $fetched->get_discount_total() );
+ $this->assertSame( '5.00000000', $fetched->get_shipping_total() );
+ $this->assertSame( '2.50000000', $fetched->get_tax_total() );
+ $this->assertSame( '2026-06-15 00:00:00', $fetched->get_last_payment_gmt() );
+ $this->assertSame( '2026-06-15 00:00:00', $fetched->get_last_attempt_gmt() );
+ $this->assertNull( $fetched->get_trial_end_gmt() );
+ $this->assertNull( $fetched->get_end_gmt() );
+
$instrument = $fetched->get_payment_instrument();
$this->assertSame( 55, $instrument->get_token_id() );
$this->assertSame( 'woocommerce_payments', $instrument->get_gateway() );
- // Items.
$items = $fetched->get_items();
$this->assertCount( 1, $items );
$this->assertSame( 'Coffee bag', $items[0]['item_name'] );
- // Addresses.
$addresses = $fetched->get_addresses();
$this->assertArrayHasKey( Contract::ADDRESS_BILLING, $addresses );
$this->assertArrayHasKey( Contract::ADDRESS_SHIPPING, $addresses );
$this->assertSame( 'Ada', $addresses[ Contract::ADDRESS_BILLING ]['first_name'] );
- // Meta.
$this->assertSame( 'pdp', $fetched->get_meta()['source_channel'] );
}
- public function test_extension_slug_defaults_to_null_when_unset(): void {
- $repo = new ContractRepository();
+ /**
+ * @testdox find_summary reads the contract row only, without children.
+ */
+ public function test_find_summary_reads_the_contract_row_only(): void {
+ $id = $this->sut->insert( $this->make_contract() );
- $id = $repo->insert(
+ $summary = $this->sut->find_summary( $id );
+
+ $this->assertInstanceOf( Contract::class, $summary );
+ $this->assertSame( $id, $summary->get_id() );
+ $this->assertSame( '2026-07-15 00:00:00', $summary->get_next_payment_gmt() );
+ $this->assertSame( array(), $summary->get_items() );
+ $this->assertSame( array(), $summary->get_meta() );
+ }
+
+ /**
+ * @testdox A manual/admin contract with a null origin order round-trips.
+ */
+ public function test_contract_round_trips_a_null_origin_order(): void {
+ $id = $this->sut->insert(
+ Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'EUR',
+ 'selling_plan_id' => 2,
+ 'start_gmt' => '2026-06-15 00:00:00',
+ )
+ )
+ );
+
+ $fetched = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $fetched );
+ $this->assertNull( $fetched->get_origin_order_id() );
+ }
+
+ /**
+ * @testdox insert_with_origin_cycle records cycle 1's snapshot refs on the contract too.
+ */
+ public function test_insert_with_origin_cycle_records_refs_on_the_contract(): void {
+ $contract = $this->make_contract();
+ $cycle = $this->make_cycle( 0, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot(), 1001 );
+ $cycle->set_status( CycleStatus::billed() );
+
+ $id = $this->sut->insert_with_origin_cycle( $contract, $cycle );
+ $this->assertGreaterThan( 0, $id );
+
+ // The signup cycle was stamped with the contract id and its snapshots resolved.
+ $this->assertSame( $id, $cycle->get_contract_id() );
+ $this->assertNotNull( $cycle->get_plan_snapshot_id() );
+ $this->assertNotNull( $cycle->get_items_snapshot_id() );
+
+ // The contract carries the SAME snapshot refs as cycle 1 (latest/live).
+ $reloaded = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $this->assertSame( $cycle->get_plan_snapshot_id(), $reloaded->get_plan_snapshot_id() );
+ $this->assertSame( $cycle->get_items_snapshot_id(), $reloaded->get_items_snapshot_id() );
+
+ // Cycle 1 is the billed signup, reachable as the chain's most-recent cycle.
+ $current = $this->sut->find_current_cycle( $id );
+ $this->assertInstanceOf( Cycle::class, $current );
+ $this->assertSame( 1, $current->get_count() );
+ $this->assertTrue( $current->get_status()->equals( CycleStatus::billed() ) );
+ }
+
+ /**
+ * @testdox extension_slug defaults to null when unset.
+ */
+ public function test_extension_slug_defaults_to_null_when_unset(): void {
+ $id = $this->sut->insert(
Contract::create(
array(
'customer_id' => 1,
@@ -114,49 +261,67 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
)
);
- $found = $repo->find( $id );
- $this->assertInstanceOf( Contract::class, $found );
- $this->assertNull( $found->get_extension_slug() );
+ $fetched = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $fetched );
+ $this->assertNull( $fetched->get_extension_slug() );
}
- public function test_update_persists_scheduling_fields(): void {
- $repo = new ContractRepository();
- $id = $repo->insert( $this->make_contract() );
+ /**
+ * @testdox update persists the contract-row cache without touching the cycle rows.
+ */
+ public function test_update_persists_the_contract_cache(): void {
+ $id = $this->sut->insert( $this->make_contract() );
- $contract = $repo->find( $id );
+ $contract = $this->sut->find( $id );
$this->assertInstanceOf( Contract::class, $contract );
$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 ) );
+ $this->assertTrue( $this->sut->update( $contract ) );
- $reloaded = $repo->find( $id );
+ $reloaded = $this->sut->find( $id );
$this->assertInstanceOf( Contract::class, $reloaded );
$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() );
+ /**
+ * @testdox update leaves unchanged child rows in place (no churn).
+ */
+ public function test_update_does_not_churn_unchanged_children(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
- $contract = $repo->find( $id );
+ $items_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $item_id_before = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$items_table} WHERE contract_id = %d", $id ) );
+
+ // A cache-only update (status) must not delete-and-reinsert the items row.
+ $contract = $this->sut->find( $id );
$this->assertInstanceOf( Contract::class, $contract );
- $this->assertCount( 1, $contract->get_items() );
+ $contract->set_status( ContractStatus::ON_HOLD );
+ $this->sut->update( $contract );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $item_id_after = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$items_table} WHERE contract_id = %d", $id ) );
+
+ $this->assertSame( $item_id_before, $item_id_after, 'An unchanged item set must keep its row id (not be rewritten).' );
+ }
+
+ /**
+ * @testdox update rewrites child rows when they change.
+ */
+ public function test_update_rewrites_changed_children(): void {
+ $id = $this->sut->insert( $this->make_contract() );
- // 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(),
+ 'customer_id' => 42,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 7,
+ 'origin_order_id' => 1001,
+ 'start_gmt' => '2026-06-15 00:00:00',
'items' => array(
array(
'item_name' => 'Tea tin',
@@ -172,9 +337,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
);
$mutated->set_id( $id );
- $this->assertTrue( $repo->update( $mutated ) );
+ $this->assertTrue( $this->sut->update( $mutated ) );
- $reloaded = $repo->find( $id );
+ $reloaded = $this->sut->find( $id );
$this->assertInstanceOf( Contract::class, $reloaded );
$items = $reloaded->get_items();
$this->assertCount( 1, $items );
@@ -182,55 +347,377 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( 'email', $reloaded->get_meta()['source_channel'] );
}
+ /**
+ * @testdox update throws when the contract has no id.
+ */
public function test_update_throws_without_id(): void {
$this->expectException( \RuntimeException::class );
- ( new ContractRepository() )->update( $this->make_contract() );
+ $this->sut->update( $this->make_contract() );
}
+ /**
+ * @testdox update rejects a deleted contract and writes no orphan child rows.
+ */
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 ) );
+ $id = $this->sut->insert( $this->make_contract() );
+ $this->assertTrue( $this->sut->delete( $id ) );
$stale = $this->make_contract();
$stale->set_id( $id );
try {
- $repo->update( $stale );
+ $this->sut->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
- );
+ $items_table = SchemaInstaller::get_table_name( 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 {
+ /**
+ * @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
+ */
+ public function test_append_cycle_and_find_current_cycle(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+ $this->sut->append_cycle( $cycle );
+
+ $this->assertNotNull( $cycle->get_id() );
+
+ $current = $this->sut->find_current_cycle( $id );
+ $this->assertInstanceOf( Cycle::class, $current );
+ $this->assertSame( $cycle->get_id(), $current->get_id() );
+ $this->assertSame( 1, $current->get_sequence_no() );
+ $this->assertSame( 1, $current->get_count() );
+ $this->assertTrue( $current->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertSame( '2026-07-15 00:00:00', $current->get_starts_at_gmt() );
+ $this->assertSame( '19.99000000', $current->get_expected_total() );
+ $this->assertSame( 'lite', $current->get_extension_slug() );
+
+ // Snapshots decoded back into typed value objects on an in-flight cycle.
+ $this->assertInstanceOf( PlanSnapshot::class, $current->get_plan_snapshot() );
+ $this->assertSame(
+ array(
+ 'selling_plan_id' => 7,
+ 'cadence' => 'monthly',
+ ),
+ $current->get_plan_snapshot()->to_array()
+ );
+ $this->assertInstanceOf( ItemsSnapshot::class, $current->get_items_snapshot() );
+ }
+
+ /**
+ * @testdox expected_total round-trips at full DECIMAL(26,8) precision, not just two decimals.
+ */
+ public function test_expected_total_round_trips_full_decimal_precision(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ // Eight fractional digits: a DECIMAL(26,2) column would truncate this on the
+ // way in, so an exact reload proves the storage scale is (26,8).
+ $cycle = Cycle::create(
+ array(
+ 'contract_id' => $id,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'starts_at_gmt' => '2026-07-15 00:00:00',
+ 'ends_at_gmt' => '2026-08-15 00:00:00',
+ 'expected_total' => '9.12345678',
+ 'currency' => 'USD',
+ 'extension_slug' => 'lite',
+ )
+ );
+ $this->sut->append_cycle( $cycle );
+
+ $reloaded = $this->sut->find_current_cycle( $id );
+ $this->assertInstanceOf( Cycle::class, $reloaded );
+ $this->assertSame( '9.12345678', $reloaded->get_expected_total() );
+ }
+
+ /**
+ * @testdox find_current_cycle returns the highest-sequence cycle in the chain.
+ */
+ public function test_find_current_cycle_returns_the_head(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $first = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
+ $this->sut->append_cycle( $first );
+
+ $second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' );
+ $this->sut->append_cycle( $second, $first );
+
+ $current = $this->sut->find_current_cycle( $id );
+ $this->assertInstanceOf( Cycle::class, $current );
+ $this->assertSame( 2, $current->get_sequence_no() );
+ }
+
+ /**
+ * @testdox find_current_cycle returns null for a chain with no cycles.
+ */
+ public function test_find_current_cycle_is_null_when_empty(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $this->assertNull( $this->sut->find_current_cycle( $id ) );
+ }
+
+ /**
+ * @testdox find_cycle_history returns a window of cycles newest first.
+ */
+ public function test_find_cycle_history_pages_newest_first(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $prev = null;
+ for ( $n = 1; $n <= 3; $n++ ) {
+ $cycle = $this->make_cycle( $id, $n, $n, sprintf( '2026-%02d-15 00:00:00', 6 + $n ), sprintf( '2026-%02d-15 00:00:00', 7 + $n ) );
+ $this->sut->append_cycle( $cycle, $prev );
+ $prev = $cycle;
+ }
+
+ $page = $this->sut->find_cycle_history( $id, Cycle::KIND_BILLING, 2, 0 );
+ $this->assertCount( 2, $page );
+ $this->assertSame( 3, $page[0]->get_sequence_no() );
+ $this->assertSame( 2, $page[1]->get_sequence_no() );
+
+ $next = $this->sut->find_cycle_history( $id, Cycle::KIND_BILLING, 2, 2 );
+ $this->assertCount( 1, $next );
+ $this->assertSame( 1, $next[0]->get_sequence_no() );
+ }
+
+ /**
+ * @testdox max_count tracks the highest count appended (the MAX(count) + 1 anchor).
+ */
+ public function test_max_count_reads_the_per_chain_counter(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $this->assertNull( $this->sut->max_count( $id ), 'An empty chain has no counting cycle.' );
+
+ $this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+ $this->assertSame( 1, $this->sut->max_count( $id ) );
+
+ $this->sut->append_cycle( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+ $this->assertSame( 2, $this->sut->max_count( $id ) );
+
+ // The next chargeable number is derived as MAX(count) + 1; appending it must
+ // advance the counter, confirming the derivation is wired through the writes.
+ $next = (int) $this->sut->max_count( $id ) + 1;
+ $this->sut->append_cycle( $this->make_cycle( $id, 3, $next, '2026-09-15 00:00:00', '2026-10-15 00:00:00' ) );
+ $this->assertSame( 3, $this->sut->max_count( $id ) );
+ }
+
+ /**
+ * @testdox find_cycles_by_order_id returns every cycle linked to an order.
+ */
+ public function test_find_cycles_by_order_id(): void {
+ $first_id = $this->sut->insert( $this->make_contract() );
+ $second_id = $this->sut->insert( $this->make_contract() );
+
+ // One aggregate order serves a cycle on each of two contracts (not 1:1).
+ $this->sut->append_cycle( $this->make_cycle( $first_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
+ $this->sut->append_cycle( $this->make_cycle( $second_id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', null, null, 9090 ) );
+ // A different order on the first contract must not match.
+ $this->sut->append_cycle( $this->make_cycle( $first_id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', null, null, 7070 ) );
+
+ $linked = $this->sut->find_cycles_by_order_id( 9090 );
+ $this->assertCount( 2, $linked );
+
+ $contract_ids = array_map(
+ static function ( Cycle $cycle ) {
+ return $cycle->get_contract_id();
+ },
+ $linked
+ );
+ sort( $contract_ids );
+ $this->assertSame( array( $first_id, $second_id ), $contract_ids );
+ }
+
+ /**
+ * @testdox update_cycle persists a status transition on a stored cycle.
+ */
+ public function test_update_cycle_persists_a_status_change(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $cycle = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' );
+ $this->sut->append_cycle( $cycle );
+
+ $cycle->set_status( CycleStatus::billed() );
+ $this->sut->update_cycle( $cycle );
+
+ $reloaded = $this->sut->find_current_cycle( $id );
+ $this->assertInstanceOf( Cycle::class, $reloaded );
+ $this->assertTrue( $reloaded->get_status()->equals( CycleStatus::billed() ) );
+ }
+
+ /**
+ * @testdox Consecutive cycles with an unchanged plan/items share one snapshot row each.
+ */
+ public function test_copy_forward_reuses_unchanged_snapshots(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+
+ $first = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+ $this->sut->append_cycle( $first );
+
+ // The next cycle's plan/items are unchanged: copy-forward should reuse the ids.
+ $second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+ $this->sut->append_cycle( $second, $first );
+
+ $this->assertSame( $first->get_plan_snapshot_id(), $second->get_plan_snapshot_id() );
+ $this->assertSame( $first->get_items_snapshot_id(), $second->get_items_snapshot_id() );
+
+ // Exactly two snapshot rows: one plan payload, one items payload.
+ $snapshots = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$snapshots} WHERE contract_id = %d", $id ) );
+ $this->assertSame( 2, $row_count );
+ }
+
+ /**
+ * @testdox A changed plan snapshot inserts a new row instead of copy-forwarding.
+ */
+ public function test_copy_forward_inserts_a_new_row_when_the_plan_changes(): void {
global $wpdb;
- $repo = new ContractRepository();
- $id = $repo->insert( $this->make_contract() );
+ $id = $this->sut->insert( $this->make_contract() );
- $this->assertTrue( $repo->delete( $id ) );
- $this->assertNull( $repo->find( $id ) );
+ $first = $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() );
+ $this->sut->append_cycle( $first );
- $items_table = \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::get_table_name(
- \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller::TABLE_CONTRACT_ITEMS
+ // The plan terms changed, so the plan snapshot must not be reused.
+ $changed_plan = PlanSnapshot::from_array(
+ array(
+ 'selling_plan_id' => 7,
+ 'cadence' => 'weekly',
+ )
);
+ $second = $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00', $changed_plan, $this->sample_items_snapshot() );
+ $this->sut->append_cycle( $second, $first );
+
+ $this->assertNotSame( $first->get_plan_snapshot_id(), $second->get_plan_snapshot_id() );
+ // The items were unchanged, so that row is still shared.
+ $this->assertSame( $first->get_items_snapshot_id(), $second->get_items_snapshot_id() );
+
+ $snapshots = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
// 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 ) );
+ $row_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$snapshots} WHERE contract_id = %d", $id ) );
+ $this->assertSame( 3, $row_count, 'Two plan payloads plus one shared items payload.' );
+ }
- $this->assertSame( '0', $remaining );
+ /**
+ * @testdox A duplicate (contract_id, kind, sequence_no) is rejected by the UNIQUE index.
+ */
+ public function test_duplicate_sequence_no_is_rejected(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+
+ $cycles_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ $cycles_table,
+ array(
+ 'contract_id' => $id,
+ 'kind' => Cycle::KIND_BILLING,
+ 'sequence_no' => 1,
+ 'count' => 99,
+ 'status' => CycleStatus::PENDING,
+ 'starts_at_gmt' => '2026-09-15 00:00:00',
+ 'ends_at_gmt' => '2026-10-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'date_created_gmt' => $now,
+ 'date_updated_gmt' => $now,
+ )
+ );
+
+ $this->assertFalse( $inserted, 'A duplicate (contract_id, kind, sequence_no) must be rejected by the UNIQUE index.' );
+ }
+
+ /**
+ * @testdox A duplicate (contract_id, kind, count) is rejected by the UNIQUE index.
+ */
+ public function test_duplicate_count_is_rejected(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+
+ $cycles_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $now = gmdate( 'Y-m-d H:i:s' );
+
+ // Same count (1) at a different sequence_no must violate UNIQUE(contract_id, kind, count).
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ $cycles_table,
+ array(
+ 'contract_id' => $id,
+ 'kind' => Cycle::KIND_BILLING,
+ 'sequence_no' => 2,
+ 'count' => 1,
+ 'status' => CycleStatus::PENDING,
+ 'starts_at_gmt' => '2026-09-15 00:00:00',
+ 'ends_at_gmt' => '2026-10-15 00:00:00',
+ 'expected_total' => '19.99',
+ 'currency' => 'USD',
+ 'date_created_gmt' => $now,
+ 'date_updated_gmt' => $now,
+ )
+ );
+
+ $this->assertFalse( $inserted, 'A duplicate (contract_id, kind, count) must be rejected by the UNIQUE index.' );
+ }
+
+ /**
+ * @testdox Multiple non-counting cycles (count = null) coexist in one chain.
+ */
+ public function test_multiple_null_count_cycles_coexist(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+
+ // MySQL treats NULLs as distinct, so two count = null cycles do not collide
+ // under UNIQUE(contract_id, kind, count).
+ $this->sut->append_cycle( $this->make_cycle( $id, 1, null, '2026-07-15 00:00:00', '2026-08-15 00:00:00' ) );
+ $this->sut->append_cycle( $this->make_cycle( $id, 2, null, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+
+ $history = $this->sut->find_cycle_history( $id );
+ $this->assertCount( 2, $history );
+ $this->assertNull( $history[0]->get_count() );
+ $this->assertNull( $history[1]->get_count() );
+
+ // No counting cycle, so the per-chain counter is null.
+ $this->assertNull( $this->sut->max_count( $id ) );
+ }
+
+ /**
+ * @testdox delete removes the contract, its children, cycles, and snapshots.
+ */
+ public function test_delete_removes_contract_children_cycles_and_snapshots(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $this->sut->append_cycle( $this->make_cycle( $id, 1, 1, '2026-07-15 00:00:00', '2026-08-15 00:00:00', $this->sample_plan_snapshot(), $this->sample_items_snapshot() ) );
+
+ $this->assertTrue( $this->sut->delete( $id ) );
+ $this->assertNull( $this->sut->find( $id ) );
+
+ foreach ( array(
+ SchemaInstaller::TABLE_CONTRACT_ITEMS,
+ SchemaInstaller::TABLE_CYCLES,
+ SchemaInstaller::TABLE_SNAPSHOTS,
+ ) as $child ) {
+ $table = SchemaInstaller::get_table_name( $child );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE contract_id = %d", $id ) );
+ $this->assertSame( '0', $remaining, "Rows must be removed from {$table} when the contract is deleted." );
+ }
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
index 1c31368d64c..d96649bef37 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SchemaInstallerTest.php
@@ -10,6 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
@@ -17,8 +18,10 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstall
*/
class SchemaInstallerTest extends EngineIntegrationTestCase {
+ use ScalarCoercion;
+
/**
- * The six baseline tables the installer owns.
+ * The baseline tables the installer owns, including the cycle tables.
*
* @return array<int, array<int, string>>
*/
@@ -30,6 +33,8 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
array( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
array( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ),
array( SchemaInstaller::TABLE_CONTRACT_META ),
+ array( SchemaInstaller::TABLE_CYCLES ),
+ array( SchemaInstaller::TABLE_SNAPSHOTS ),
);
}
@@ -66,6 +71,28 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
SchemaInstaller::get_table_name( 'not_a_table' );
}
+ /**
+ * @testdox A chain is the pair (contract_id, kind), so there is no chains table.
+ */
+ public function test_there_is_no_chains_table(): void {
+ global $wpdb;
+
+ $chains = $wpdb->prefix . 'wc_subscription_chains';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $chains ) );
+
+ $this->assertNull( $found, 'Did not expect a wc_subscription_chains table.' );
+ }
+
+ /**
+ * @testdox The chains logical identifier is no longer registered.
+ */
+ public function test_chains_logical_identifier_is_unknown(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ SchemaInstaller::get_table_name( 'chains' );
+ }
+
public function test_plans_table_has_extension_slug_column(): void {
global $wpdb;
@@ -87,4 +114,402 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
$this->assertSame( 'extension_slug', $column );
}
+
+ public function test_contracts_table_has_the_live_source_of_truth_columns(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ $columns = array(
+ 'id',
+ 'status',
+ 'customer_id',
+ 'currency',
+ 'selling_plan_id',
+ 'origin_order_id',
+ 'extension_slug',
+ 'payment_method',
+ 'payment_method_title',
+ 'payment_token_id',
+ 'start_gmt',
+ 'next_payment_gmt',
+ 'plan_snapshot_id',
+ 'items_snapshot_id',
+ 'billing_total',
+ 'discount_total',
+ 'shipping_total',
+ 'tax_total',
+ 'last_payment_gmt',
+ 'last_attempt_gmt',
+ 'trial_end_gmt',
+ 'end_gmt',
+ 'schedule_source',
+ );
+
+ foreach ( $columns as $column ) {
+ $this->assertTrue( $this->has_column( $table, $column ), "Expected contracts.{$column} column." );
+ }
+ }
+
+ /**
+ * @testdox origin_order_id is nullable so a manual/admin contract can omit it.
+ */
+ public function test_contracts_origin_order_id_is_nullable(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'origin_order_id' ), ARRAY_A );
+
+ $this->assertIsArray( $row, 'Expected a contracts.origin_order_id column.' );
+ $this->assertArrayHasKey( 'Null', $row );
+ $this->assertIsString( $row['Null'] );
+ $this->assertSame( 'YES', $row['Null'], 'Expected contracts.origin_order_id to be NULLable.' );
+ }
+
+ /**
+ * @testdox The contract row has no generic cycle_count - counters are per-chain and derived.
+ */
+ public function test_contracts_table_has_no_generic_cycle_count(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ $this->assertFalse(
+ $this->has_column( $table, 'cycle_count' ),
+ 'Did not expect a generic contracts.cycle_count column; per-chain counts are derived from the cycle rows.'
+ );
+ }
+
+ /**
+ * @testdox The contract carries the live config: the four totals and the four stamps.
+ */
+ public function test_contracts_table_carries_live_config_columns(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ // These are live values on the contract (the source of truth), not caches of
+ // cycles: the four recurring totals and the four lifecycle stamps.
+ $live_config = array(
+ 'billing_total',
+ 'discount_total',
+ 'shipping_total',
+ 'tax_total',
+ 'last_payment_gmt',
+ 'last_attempt_gmt',
+ 'trial_end_gmt',
+ 'end_gmt',
+ );
+
+ foreach ( $live_config as $column ) {
+ $this->assertTrue( $this->has_column( $table, $column ), "Expected the live contracts.{$column} column." );
+ }
+ }
+
+ /**
+ * @testdox The contract carries the latest/live snapshot references.
+ */
+ public function test_contracts_table_carries_latest_snapshot_refs(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ $this->assertTrue( $this->has_column( $table, 'plan_snapshot_id' ), 'Expected contracts.plan_snapshot_id.' );
+ $this->assertTrue( $this->has_column( $table, 'items_snapshot_id' ), 'Expected contracts.items_snapshot_id.' );
+ }
+
+ public function test_contracts_due_index_keys_the_next_bill_cache(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ $this->assertContains( 'due', $this->index_names( $table ) );
+ $this->assertSame(
+ array( 'next_payment_gmt', 'status' ),
+ $this->index_columns( $table, 'due' )
+ );
+ }
+
+ public function test_cycles_table_has_expected_columns(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $columns = array(
+ 'id',
+ 'contract_id',
+ 'kind',
+ 'sequence_no',
+ 'count',
+ 'status',
+ 'reason',
+ 'starts_at_gmt',
+ 'ends_at_gmt',
+ 'expected_total',
+ 'currency',
+ 'plan_snapshot_id',
+ 'items_snapshot_id',
+ 'order_id',
+ 'extension_slug',
+ );
+
+ foreach ( $columns as $column ) {
+ $this->assertTrue( $this->has_column( $table, $column ), "Expected cycles.{$column} column." );
+ }
+ }
+
+ /**
+ * @testdox A chain is (contract_id, kind), so cycles carry no chain_id column.
+ */
+ public function test_cycles_table_has_no_chain_id_column(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $this->assertFalse(
+ $this->has_column( $table, 'chain_id' ),
+ 'Did not expect a cycles.chain_id column; a chain is the pair (contract_id, kind).'
+ );
+ }
+
+ public function test_cycles_table_has_all_expected_indexes(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+ $indexes = $this->index_names( $table );
+
+ $this->assertContains( 'chain_seq', $indexes, 'Expected the UNIQUE (contract_id, kind, sequence_no) index.' );
+ $this->assertContains( 'chain_count', $indexes, 'Expected the UNIQUE (contract_id, kind, count) index.' );
+ $this->assertContains( 'due', $indexes, 'Expected the due (kind, status, starts_at_gmt) index.' );
+ $this->assertContains( 'order_id', $indexes, 'Expected the (order_id) index.' );
+ $this->assertContains( 'contract_kind', $indexes, 'Expected the (contract_id, kind) index.' );
+ }
+
+ public function test_cycles_chain_sequence_index_columns_are_in_order(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $this->assertSame(
+ array( 'contract_id', 'kind', 'sequence_no' ),
+ $this->index_columns( $table, 'chain_seq' )
+ );
+ }
+
+ public function test_cycles_chain_count_index_columns_are_in_order(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $this->assertSame(
+ array( 'contract_id', 'kind', 'count' ),
+ $this->index_columns( $table, 'chain_count' )
+ );
+ }
+
+ public function test_cycles_due_index_columns_are_in_scan_order(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // The dispatcher scans the due index as (kind, status, starts_at_gmt); the
+ // column order is load-bearing, so assert it exactly, not just existence.
+ $this->assertSame(
+ array( 'kind', 'status', 'starts_at_gmt' ),
+ $this->index_columns( $table, 'due' )
+ );
+ }
+
+ public function test_cycles_contract_kind_index_columns_are_in_order(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $this->assertSame(
+ array( 'contract_id', 'kind' ),
+ $this->index_columns( $table, 'contract_kind' )
+ );
+ }
+
+ public function test_cycles_chain_sequence_index_is_unique(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $this->assertTrue(
+ $this->index_is_unique( $table, 'chain_seq' ),
+ 'Expected the (contract_id, kind, sequence_no) index to be UNIQUE.'
+ );
+ }
+
+ /**
+ * @testdox The chain_count index is UNIQUE so (contract_id, kind, count) is the per-charge idempotency anchor.
+ */
+ public function test_cycles_chain_count_index_is_unique(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ $this->assertTrue(
+ $this->index_is_unique( $table, 'chain_count' ),
+ 'Expected the (contract_id, kind, count) index to be UNIQUE.'
+ );
+ }
+
+ /**
+ * @testdox A nullable count lets multiple non-counting cycles coexist under the UNIQUE chain_count index.
+ */
+ public function test_cycles_count_column_is_nullable(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CYCLES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'count' ), ARRAY_A );
+
+ $this->assertIsArray( $row, 'Expected a cycles.count column.' );
+ $this->assertArrayHasKey( 'Null', $row );
+ $this->assertIsString( $row['Null'] );
+ $this->assertSame( 'YES', $row['Null'], 'Expected cycles.count to be NULLable so NULL counts coexist.' );
+ }
+
+ public function test_snapshots_table_has_expected_columns(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ $columns = array(
+ 'id',
+ 'contract_id',
+ 'snapshot_type',
+ 'parent_id',
+ 'schema_version',
+ 'payload',
+ 'date_created_gmt',
+ );
+
+ foreach ( $columns as $column ) {
+ $this->assertTrue( $this->has_column( $table, $column ), "Expected snapshots.{$column} column." );
+ }
+ }
+
+ /**
+ * @testdox Snapshots are deduped by copy-forward, so there is no content_hash column.
+ */
+ public function test_snapshots_table_has_no_content_hash_column(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ $this->assertFalse(
+ $this->has_column( $table, 'content_hash' ),
+ 'Did not expect a snapshots.content_hash column; dedup is by copy-forward, not content hashing.'
+ );
+ }
+
+ /**
+ * @testdox The owner lives on the cycle, not the snapshot, so snapshots have no extension_slug column.
+ */
+ public function test_snapshots_table_has_no_extension_slug_column(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ $this->assertFalse(
+ $this->has_column( $table, 'extension_slug' ),
+ 'Did not expect a snapshots.extension_slug column; the owner is recorded on the cycle.'
+ );
+ }
+
+ public function test_snapshots_table_has_contract_type_and_parent_indexes(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+ $indexes = $this->index_names( $table );
+
+ $this->assertContains( 'contract_type', $indexes, 'Expected the (contract_id, snapshot_type) index.' );
+ $this->assertContains( 'parent', $indexes, 'Expected the (parent_id) index.' );
+ }
+
+ public function test_snapshots_contract_type_index_columns_are_in_order(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ $this->assertSame(
+ array( 'contract_id', 'snapshot_type' ),
+ $this->index_columns( $table, 'contract_type' )
+ );
+ }
+
+ public function test_snapshots_parent_index_columns_are_in_order(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ $this->assertSame(
+ array( 'parent_id' ),
+ $this->index_columns( $table, 'parent' )
+ );
+ }
+
+ /**
+ * Whether `$table` has a column named `$column`.
+ *
+ * @param string $table Prefixed table name.
+ * @param string $column Column name.
+ */
+ private function has_column( string $table, string $column ): bool {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ) );
+
+ return $column === $found;
+ }
+
+ /**
+ * The set of index names defined on `$table`.
+ *
+ * @param string $table Prefixed table name.
+ * @return array<int, string>
+ */
+ private function index_names( string $table ): array {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( "SHOW INDEX FROM {$table}", ARRAY_A );
+
+ $names = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ if ( is_array( $row ) ) {
+ $names[] = self::coerce_string( $row['Key_name'] ?? null );
+ }
+ }
+
+ return array_values( array_unique( $names ) );
+ }
+
+ /**
+ * The ordered column names of a named index on `$table`.
+ *
+ * @param string $table Prefixed table name.
+ * @param string $key_name Index name.
+ * @return array<int, string> Column names in index order (by Seq_in_index).
+ */
+ private function index_columns( string $table, string $key_name ): array {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM {$table} WHERE Key_name = %s", $key_name ), ARRAY_A );
+
+ $rows = is_array( $rows ) ? $rows : array();
+ usort(
+ $rows,
+ static function ( $a, $b ): int {
+ $a_seq = is_array( $a ) ? self::coerce_int( $a['Seq_in_index'] ?? null ) : 0;
+ $b_seq = is_array( $b ) ? self::coerce_int( $b['Seq_in_index'] ?? null ) : 0;
+
+ return $a_seq <=> $b_seq;
+ }
+ );
+
+ $columns = array();
+ foreach ( $rows as $row ) {
+ if ( is_array( $row ) ) {
+ $columns[] = self::coerce_string( $row['Column_name'] ?? null );
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Whether the named index on `$table` is UNIQUE.
+ *
+ * @param string $table Prefixed table name.
+ * @param string $key_name Index name.
+ */
+ private function index_is_unique( string $table, string $key_name ): bool {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM {$table} WHERE Key_name = %s", $key_name ), ARRAY_A );
+
+ $rows = is_array( $rows ) ? $rows : array();
+ if ( empty( $rows ) ) {
+ return false;
+ }
+
+ foreach ( $rows as $row ) {
+ // Non_unique = 0 marks a UNIQUE index.
+ if ( is_array( $row ) && '0' !== self::coerce_string( $row['Non_unique'] ?? null ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
new file mode 100644
index 00000000000..6b2ce8129d0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * Integration tests for SnapshotStore.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore
+ */
+class SnapshotStoreTest extends EngineIntegrationTestCase {
+
+ use ScalarCoercion;
+
+ /**
+ * The System Under Test.
+ *
+ * @var SnapshotStore
+ */
+ private $sut;
+
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new SnapshotStore();
+ }
+
+ /**
+ * Count the rows in the snapshots table.
+ */
+ private function snapshot_row_count(): int {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+ }
+
+ /**
+ * Read a snapshot row by id.
+ *
+ * @param int $id Snapshot row id.
+ * @return array<string, mixed>|null
+ */
+ private function snapshot_row( int $id ): ?array {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_SNAPSHOTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+ return null === $row ? null : $row;
+ }
+
+ /**
+ * @testdox insert() writes a snapshot row and returns its id.
+ */
+ public function test_insert_writes_a_row_and_returns_its_id(): void {
+ $id = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 1 );
+
+ $this->assertGreaterThan( 0, $id );
+ $this->assertSame( 1, $this->snapshot_row_count() );
+ }
+
+ /**
+ * @testdox insert() persists the typed columns (contract, type, parent, schema version).
+ */
+ public function test_insert_persists_the_typed_columns(): void {
+ $id = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 2 );
+
+ $row = $this->snapshot_row( $id );
+ $this->assertNotNull( $row );
+ $this->assertSame( '100', self::coerce_string( $row['contract_id'] ?? null ) );
+ $this->assertSame( SnapshotStore::TYPE_PLAN, $row['snapshot_type'] );
+ $this->assertSame( '7', self::coerce_string( $row['parent_id'] ?? null ) );
+ $this->assertSame( '2', self::coerce_string( $row['schema_version'] ?? null ) );
+ }
+
+ /**
+ * @testdox insert() stores an items snapshot with a null parent_id.
+ */
+ public function test_insert_stores_items_snapshot_with_null_parent(): void {
+ $id = $this->sut->insert( 100, SnapshotStore::TYPE_ITEMS, null, array( array( 'product_id' => 200 ) ), 1 );
+
+ $row = $this->snapshot_row( $id );
+ $this->assertNotNull( $row );
+ $this->assertSame( SnapshotStore::TYPE_ITEMS, $row['snapshot_type'] );
+ $this->assertNull( $row['parent_id'] );
+ }
+
+ /**
+ * @testdox insert() JSON-encodes the payload and round-trips it.
+ */
+ public function test_insert_round_trips_the_payload(): void {
+ $payload = array(
+ 'selling_plan_id' => 7,
+ 'name' => 'Monthly box',
+ );
+
+ $id = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, $payload, 1 );
+ $row = $this->snapshot_row( $id );
+
+ $this->assertNotNull( $row );
+ $this->assertSame( $payload, json_decode( self::coerce_string( $row['payload'] ?? null ), true ) );
+ }
+
+ /**
+ * @testdox insert() writes a distinct row per call (dedup is the caller's copy-forward).
+ */
+ public function test_insert_writes_a_distinct_row_per_call(): void {
+ $first = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 1 );
+ $second = $this->sut->insert( 100, SnapshotStore::TYPE_PLAN, 7, array( 'selling_plan_id' => 7 ), 1 );
+
+ $this->assertNotSame( $first, $second, 'Each insert() is a fresh row; reuse is decided by the caller, not the store.' );
+ $this->assertSame( 2, $this->snapshot_row_count() );
+ }
+}
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
index 095562ef9d2..862482b0f46 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -1,11 +1,12 @@
<?php
/**
- * Unit tests for Contract construction invariants.
+ * Unit tests for the live Contract entity.
*
- * Confirms that Contract::from_storage() enforces the same boundary invariants
- * as the entity's setters (a corrupted or migrated row cannot smuggle an illegal
- * value past hydration), and that Contract::create() rejects missing or invalid
- * required fields rather than coercing them to a silent default.
+ * Covers the live source-of-truth shape: stable identity plus the live schedule,
+ * the latest/live snapshot references, and the live config values (the four totals
+ * and the four stamps). The contract holds no in-memory cycle graph and no generic
+ * cycle_count (counters are per-chain and derived from the cycle rows), and
+ * origin_order_id may be null.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -18,6 +19,7 @@ use DomainException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract
@@ -27,107 +29,335 @@ 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 {
+ private function valid_row(): 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,
+ 'id' => 10,
+ 'status' => ContractStatus::ACTIVE,
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'extension_slug' => 'acme-subs',
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ 'plan_snapshot_id' => 11,
+ 'items_snapshot_id' => 22,
+ 'billing_total' => '20.00',
+ 'discount_total' => '0',
+ 'shipping_total' => '5.00',
+ 'tax_total' => '2.50',
+ 'last_payment_gmt' => '2026-01-01 00:00:00',
+ 'last_attempt_gmt' => '2026-01-01 00:00:00',
+ 'trial_end_gmt' => null,
+ 'end_gmt' => null,
+ 'schedule_source' => Contract::SCHEDULE_SOURCE_PRIMITIVE,
);
}
- public function test_from_storage_hydrates_cycle_count(): void {
- $contract = Contract::from_storage( $this->valid_row( 3 ) );
-
- $this->assertSame( 3, $contract->get_cycle_count() );
+ private function make_contract(): Contract {
+ return Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'extension_slug' => 'acme-subs',
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ )
+ );
}
- public function test_from_storage_rejects_negative_cycle_count(): void {
- $this->expectException( DomainException::class );
+ /**
+ * @testdox create() builds an active contract from its identity and live config.
+ */
+ public function test_create_builds_an_active_contract(): void {
+ $contract = $this->make_contract();
- Contract::from_storage( $this->valid_row( -1 ) );
+ $this->assertNull( $contract->get_id() );
+ $this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
+ $this->assertSame( 1, $contract->get_customer_id() );
+ $this->assertSame( 'USD', $contract->get_currency() );
+ $this->assertSame( 2, $contract->get_selling_plan_id() );
+ $this->assertSame( 3, $contract->get_origin_order_id() );
+ $this->assertSame( 'acme-subs', $contract->get_extension_slug() );
+ $this->assertSame( '2026-01-01 00:00:00', $contract->get_start_gmt() );
+ $this->assertSame( '2026-02-01 00:00:00', $contract->get_next_payment_gmt() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_PRIMITIVE, $contract->get_schedule_source() );
}
- public function test_from_storage_rejects_non_integer_cycle_count(): void {
- $row = $this->valid_row( 0 );
- $row['cycle_count'] = '1.5';
+ /**
+ * @testdox create() defaults the live config to empty values.
+ */
+ public function test_create_defaults_live_config(): void {
+ $contract = Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ )
+ );
- $this->expectException( DomainException::class );
+ $this->assertNull( $contract->get_next_payment_gmt() );
+ $this->assertNull( $contract->get_extension_slug() );
+ $this->assertNull( $contract->get_origin_order_id() );
+ $this->assertNull( $contract->get_plan_snapshot_id() );
+ $this->assertNull( $contract->get_items_snapshot_id() );
+ $this->assertSame( '0.00000000', $contract->get_billing_total() );
+ $this->assertSame( '0.00000000', $contract->get_discount_total() );
+ $this->assertSame( '0.00000000', $contract->get_shipping_total() );
+ $this->assertSame( '0.00000000', $contract->get_tax_total() );
+ $this->assertNull( $contract->get_last_payment_gmt() );
+ $this->assertNull( $contract->get_last_attempt_gmt() );
+ $this->assertNull( $contract->get_trial_end_gmt() );
+ $this->assertNull( $contract->get_end_gmt() );
+ }
+
+ /**
+ * @testdox create() normalizes the live totals to the storage scale.
+ */
+ public function test_create_normalizes_live_totals(): void {
+ $contract = Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'billing_total' => '20.00',
+ 'discount_total' => '1.5',
+ 'shipping_total' => '5',
+ 'tax_total' => '2.345',
+ )
+ );
- Contract::from_storage( $row );
+ $this->assertSame( '20.00000000', $contract->get_billing_total() );
+ $this->assertSame( '1.50000000', $contract->get_discount_total() );
+ $this->assertSame( '5.00000000', $contract->get_shipping_total() );
+ $this->assertSame( '2.34500000', $contract->get_tax_total() );
}
- public function test_from_storage_rejects_invalid_schedule_source(): void {
- $row = $this->valid_row( 1 );
- $row['schedule_source'] = 'bogus';
+ /**
+ * @testdox create() allows a null origin_order_id (a manual/admin contract).
+ */
+ public function test_create_allows_a_null_origin_order_id(): void {
+ $contract = Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ )
+ );
+
+ $this->assertNull( $contract->get_origin_order_id() );
+ }
+ /**
+ * @testdox create() rejects an invalid status.
+ */
+ public function test_create_rejects_an_invalid_status(): void {
$this->expectException( DomainException::class );
- Contract::from_storage( $row );
+ Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'status' => 'nonsense',
+ )
+ );
}
/**
- * A minimal, valid set of Contract::create() arguments.
- *
- * @return array<string, mixed>
+ * @testdox create() rejects an invalid schedule source.
*/
- private function valid_create_args(): array {
- return array(
- 'customer_id' => 1,
- 'currency' => 'USD',
- 'selling_plan_id' => 2,
- 'origin_order_id' => 3,
- 'start_gmt' => '2026-01-01 00:00:00',
+ public function test_create_rejects_an_invalid_schedule_source(): void {
+ $this->expectException( DomainException::class );
+
+ Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'schedule_source' => 'nonsense',
+ )
);
}
- public function test_create_succeeds_with_valid_required_fields(): void {
- $contract = Contract::create( $this->valid_create_args() );
+ /**
+ * @testdox The payment instrument round-trips through an InstrumentRef.
+ */
+ public function test_payment_instrument_round_trips(): void {
+ $contract = $this->make_contract();
+ $contract->set_payment_instrument( new InstrumentRef( 99, 'dummy', 'Dummy Gateway' ) );
+
+ $instrument = $contract->get_payment_instrument();
+ $this->assertSame( 99, $instrument->get_token_id() );
+ $this->assertSame( 'dummy', $instrument->get_gateway() );
+ $this->assertSame( 'Dummy Gateway', $instrument->get_title() );
+ }
+
+ /**
+ * @testdox The live schedule is replaceable.
+ */
+ public function test_next_payment_schedule_is_replaceable(): void {
+ $contract = $this->make_contract();
+
+ $contract->set_next_payment_gmt( '2026-03-01 00:00:00' );
+ $this->assertSame( '2026-03-01 00:00:00', $contract->get_next_payment_gmt() );
+
+ $contract->set_next_payment_gmt( null );
+ $this->assertNull( $contract->get_next_payment_gmt() );
+ }
+
+ /**
+ * @testdox The live snapshot references and stamps are settable over the contract's life.
+ */
+ public function test_live_snapshot_refs_and_stamps_are_settable(): void {
+ $contract = $this->make_contract();
+
+ $contract->set_plan_snapshot_id( 11 );
+ $contract->set_items_snapshot_id( 22 );
+ $contract->set_billing_total( '49.00' );
+ $contract->set_discount_total( '1.00' );
+ $contract->set_shipping_total( '5.00' );
+ $contract->set_tax_total( '2.50' );
+ $contract->set_last_payment_gmt( '2026-02-01 00:00:00' );
+ $contract->set_last_attempt_gmt( '2026-02-01 00:00:00' );
+ $contract->set_trial_end_gmt( '2026-01-15 00:00:00' );
+ $contract->set_end_gmt( '2027-01-01 00:00:00' );
+
+ $this->assertSame( 11, $contract->get_plan_snapshot_id() );
+ $this->assertSame( 22, $contract->get_items_snapshot_id() );
+ $this->assertSame( '49.00000000', $contract->get_billing_total() );
+ $this->assertSame( '1.00000000', $contract->get_discount_total() );
+ $this->assertSame( '5.00000000', $contract->get_shipping_total() );
+ $this->assertSame( '2.50000000', $contract->get_tax_total() );
+ $this->assertSame( '2026-02-01 00:00:00', $contract->get_last_payment_gmt() );
+ $this->assertSame( '2026-02-01 00:00:00', $contract->get_last_attempt_gmt() );
+ $this->assertSame( '2026-01-15 00:00:00', $contract->get_trial_end_gmt() );
+ $this->assertSame( '2027-01-01 00:00:00', $contract->get_end_gmt() );
+ }
+
+ /**
+ * @testdox The live snapshot reference can be re-pointed (unlike a cycle's frozen ref).
+ */
+ public function test_live_snapshot_ref_can_be_repointed(): void {
+ $contract = $this->make_contract();
+
+ // The contract holds the latest/live snapshot ref and re-points it when the
+ // plan changes; this is the intentional contrast with a cycle's write-once ref.
+ $contract->set_plan_snapshot_id( 11 );
+ $contract->set_plan_snapshot_id( 99 );
+
+ $this->assertSame( 99, $contract->get_plan_snapshot_id() );
+ }
+
+ /**
+ * @testdox from_storage() hydrates the identity, schedule, refs, and live config.
+ */
+ public function test_from_storage_hydrates_the_live_state(): void {
+ $contract = Contract::from_storage( $this->valid_row() );
+
+ $this->assertSame( 10, $contract->get_id() );
+ $this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
$this->assertSame( 1, $contract->get_customer_id() );
- $this->assertSame( 'USD', $contract->get_currency() );
$this->assertSame( 2, $contract->get_selling_plan_id() );
$this->assertSame( 3, $contract->get_origin_order_id() );
+ $this->assertSame( 'acme-subs', $contract->get_extension_slug() );
+ $this->assertSame( '2026-02-01 00:00:00', $contract->get_next_payment_gmt() );
+ $this->assertSame( 11, $contract->get_plan_snapshot_id() );
+ $this->assertSame( 22, $contract->get_items_snapshot_id() );
+ $this->assertSame( '20.00000000', $contract->get_billing_total() );
+ $this->assertSame( '0.00000000', $contract->get_discount_total() );
+ $this->assertSame( '5.00000000', $contract->get_shipping_total() );
+ $this->assertSame( '2.50000000', $contract->get_tax_total() );
+ $this->assertSame( '2026-01-01 00:00:00', $contract->get_last_payment_gmt() );
+ $this->assertSame( '2026-01-01 00:00:00', $contract->get_last_attempt_gmt() );
+ $this->assertNull( $contract->get_trial_end_gmt() );
+ $this->assertNull( $contract->get_end_gmt() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_PRIMITIVE, $contract->get_schedule_source() );
}
- public function test_create_allows_guest_customer_id_zero(): void {
- $contract = Contract::create(
- array_merge( $this->valid_create_args(), array( 'customer_id' => 0 ) )
- );
+ /**
+ * @testdox from_storage() hydrates a manual/admin contract with a null origin order.
+ */
+ public function test_from_storage_hydrates_a_null_origin_order(): void {
+ $row = $this->valid_row();
+ $row['origin_order_id'] = null;
+
+ $contract = Contract::from_storage( $row );
- $this->assertSame( 0, $contract->get_customer_id() );
+ $this->assertNull( $contract->get_origin_order_id() );
}
/**
- * @dataProvider provide_invalid_create_args
- * @param array<string, mixed> $overrides Field overrides that should make create() reject the args.
+ * @testdox from_storage() hydrates items, addresses, and meta children.
*/
- public function test_create_rejects_invalid_required_fields( array $overrides ): void {
- $this->expectException( DomainException::class );
+ public function test_from_storage_hydrates_children(): void {
+ $items = array( array( 'product_id' => 42 ) );
+ $addresses = array( 'billing' => array( 'first_name' => 'Ada' ) );
+ $meta = array( 'flag' => 'on' );
+
+ $contract = Contract::from_storage( $this->valid_row(), $items, $addresses, $meta );
- Contract::create( array_merge( $this->valid_create_args(), $overrides ) );
+ $this->assertSame( $items, $contract->get_items() );
+ $this->assertSame( $addresses, $contract->get_addresses() );
+ $this->assertSame( $meta, $contract->get_meta() );
}
/**
- * @return array<string, array{0: array<string, mixed>}>
+ * @testdox to_storage() carries the full live column set.
*/
- public function provide_invalid_create_args(): array {
- return array(
- 'missing customer_id' => array( array( 'customer_id' => null ) ),
- 'zero selling_plan_id' => array( array( 'selling_plan_id' => 0 ) ),
- 'non-numeric origin_order_id' => array( array( 'origin_order_id' => 'x' ) ),
- 'missing currency' => array( array( 'currency' => null ) ),
- 'empty currency' => array( array( 'currency' => '' ) ),
- 'missing start_gmt' => array( array( 'start_gmt' => null ) ),
+ public function test_to_storage_carries_the_live_column_set(): void {
+ $row = $this->make_contract()->to_storage();
+
+ // Assert the key SET, not the insertion order: the row's column order is
+ // not load-bearing, so canonicalize to avoid a brittle ordering coupling.
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'status',
+ 'customer_id',
+ 'currency',
+ 'selling_plan_id',
+ 'origin_order_id',
+ 'extension_slug',
+ 'payment_method',
+ 'payment_method_title',
+ 'payment_token_id',
+ 'start_gmt',
+ 'next_payment_gmt',
+ 'plan_snapshot_id',
+ 'items_snapshot_id',
+ 'billing_total',
+ 'discount_total',
+ 'shipping_total',
+ 'tax_total',
+ 'last_payment_gmt',
+ 'last_attempt_gmt',
+ 'trial_end_gmt',
+ 'end_gmt',
+ 'schedule_source',
+ ),
+ array_keys( $row )
);
}
+
+ /**
+ * @testdox to_storage() does not carry a generic cycle_count column.
+ */
+ public function test_to_storage_has_no_generic_cycle_count(): void {
+ $row = $this->make_contract()->to_storage();
+
+ $this->assertArrayNotHasKey( 'cycle_count', $row, 'to_storage() must not carry a generic cycle_count; counters are per-chain and derived.' );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
new file mode 100644
index 00000000000..b4a8ac085a2
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Unit tests for the CycleStatus state machine.
+ *
+ * @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\CycleStatus;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus
+ */
+class CycleStatusTest extends TestCase {
+
+ public function test_all_returns_exactly_the_known_statuses(): void {
+ $this->assertSame(
+ array(
+ CycleStatus::PENDING,
+ CycleStatus::BILLED,
+ CycleStatus::FAILED,
+ CycleStatus::CANCELLED,
+ ),
+ CycleStatus::all()
+ );
+ }
+
+ public function test_known_statuses_are_valid(): void {
+ $this->assertTrue( CycleStatus::is_valid( CycleStatus::PENDING ) );
+ $this->assertTrue( CycleStatus::is_valid( CycleStatus::BILLED ) );
+ $this->assertTrue( CycleStatus::is_valid( CycleStatus::FAILED ) );
+ $this->assertTrue( CycleStatus::is_valid( CycleStatus::CANCELLED ) );
+ $this->assertFalse( CycleStatus::is_valid( 'nonsense' ) );
+ }
+
+ public function test_pending_reaches_billed_failed_and_cancelled(): void {
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ) );
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::FAILED ) );
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::CANCELLED ) );
+ }
+
+ public function test_failed_can_only_be_cancelled(): void {
+ // Retry support (failed -> pending re-queue) is deferred; for now a failed cycle can only be cancelled.
+ $this->assertTrue( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::CANCELLED ) );
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::PENDING ) );
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED ) );
+ }
+
+ public function test_billed_and_cancelled_are_terminal(): void {
+ $this->assertTrue( CycleStatus::is_terminal( CycleStatus::BILLED ) );
+ $this->assertTrue( CycleStatus::is_terminal( CycleStatus::CANCELLED ) );
+
+ foreach ( CycleStatus::all() as $target ) {
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::BILLED, $target ) );
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::CANCELLED, $target ) );
+ }
+ }
+
+ public function test_pending_and_failed_are_not_terminal(): void {
+ $this->assertFalse( CycleStatus::is_terminal( CycleStatus::PENDING ) );
+ $this->assertFalse( CycleStatus::is_terminal( CycleStatus::FAILED ) );
+ }
+
+ public function test_unknown_statuses_never_transition(): void {
+ $this->assertFalse( CycleStatus::is_transition_allowed( 'nonsense', CycleStatus::BILLED ) );
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, 'nonsense' ) );
+ }
+
+ public function test_same_status_is_not_an_allowed_transition(): void {
+ $this->assertFalse( CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::PENDING ) );
+ }
+
+ public function test_can_transition_aliases_is_transition_allowed(): void {
+ $this->assertSame(
+ CycleStatus::is_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED ),
+ CycleStatus::can_transition( CycleStatus::PENDING, CycleStatus::BILLED )
+ );
+ $this->assertSame(
+ CycleStatus::is_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING ),
+ CycleStatus::can_transition( CycleStatus::BILLED, CycleStatus::PENDING )
+ );
+ }
+
+ public function test_assert_transition_allowed_passes_for_a_legal_move(): void {
+ CycleStatus::assert_transition_allowed( CycleStatus::PENDING, CycleStatus::BILLED );
+
+ // No exception thrown.
+ $this->addToAssertionCount( 1 );
+ }
+
+ public function test_assert_transition_allowed_throws_for_an_illegal_move(): void {
+ $this->expectException( DomainException::class );
+
+ CycleStatus::assert_transition_allowed( CycleStatus::FAILED, CycleStatus::BILLED );
+ }
+
+ public function test_assert_transition_allowed_throws_out_of_a_terminal_status(): void {
+ $this->expectException( DomainException::class );
+
+ CycleStatus::assert_transition_allowed( CycleStatus::BILLED, CycleStatus::PENDING );
+ }
+
+ public function test_named_factories_carry_their_status_value(): void {
+ $this->assertSame( CycleStatus::PENDING, CycleStatus::pending()->get_value() );
+ $this->assertSame( CycleStatus::BILLED, CycleStatus::billed()->get_value() );
+ $this->assertSame( CycleStatus::FAILED, CycleStatus::failed()->get_value() );
+ $this->assertSame( CycleStatus::CANCELLED, CycleStatus::cancelled()->get_value() );
+ }
+
+ public function test_from_builds_a_known_status(): void {
+ $this->assertSame( CycleStatus::PENDING, CycleStatus::from( CycleStatus::PENDING )->get_value() );
+ }
+
+ public function test_from_rejects_an_unknown_status(): void {
+ $this->expectException( DomainException::class );
+
+ CycleStatus::from( 'nonsense' );
+ }
+
+ public function test_equals_compares_by_value(): void {
+ $this->assertTrue( CycleStatus::pending()->equals( CycleStatus::pending() ) );
+ $this->assertFalse( CycleStatus::pending()->equals( CycleStatus::billed() ) );
+ }
+
+ public function test_can_transition_to_mirrors_the_static_table(): void {
+ $this->assertTrue( CycleStatus::pending()->can_transition_to( CycleStatus::billed() ) );
+ $this->assertFalse( CycleStatus::failed()->can_transition_to( CycleStatus::billed() ) );
+ }
+
+ public function test_transition_to_returns_the_target_for_a_legal_move(): void {
+ $next = CycleStatus::pending()->transition_to( CycleStatus::billed() );
+
+ $this->assertTrue( $next->equals( CycleStatus::billed() ) );
+ }
+
+ public function test_transition_to_throws_for_an_illegal_move(): void {
+ $this->expectException( DomainException::class );
+
+ CycleStatus::failed()->transition_to( CycleStatus::billed() );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
new file mode 100644
index 00000000000..e695a67e070
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -0,0 +1,445 @@
+<?php
+/**
+ * Unit tests for the Cycle entity.
+ *
+ * @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\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle
+ */
+class CycleTest extends TestCase {
+
+ /**
+ * Build a pending billing cycle with sensible defaults, overridable per test.
+ *
+ * @param array<string, mixed> $overrides Field overrides.
+ */
+ private function make_pending( array $overrides = array() ): Cycle {
+ return Cycle::create(
+ array_merge(
+ array(
+ 'contract_id' => 7,
+ 'sequence_no' => 1,
+ 'count' => 1,
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ ),
+ $overrides
+ )
+ );
+ }
+
+ public function test_create_builds_a_pending_billing_cycle(): void {
+ $cycle = $this->make_pending();
+
+ $this->assertNull( $cycle->get_id() );
+ $this->assertSame( 7, $cycle->get_contract_id() );
+ $this->assertSame( 1, $cycle->get_sequence_no() );
+ $this->assertSame( 1, $cycle->get_count() );
+ $this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+ $this->assertNull( $cycle->get_reason() );
+ $this->assertSame( '2026-02-01 00:00:00', $cycle->get_starts_at_gmt() );
+ $this->assertSame( '2026-03-01 00:00:00', $cycle->get_ends_at_gmt() );
+ $this->assertSame( '20.00000000', $cycle->get_expected_total() );
+ $this->assertSame( 'USD', $cycle->get_currency() );
+ $this->assertNull( $cycle->get_order_id() );
+ $this->assertNull( $cycle->get_extension_slug() );
+ $this->assertNull( $cycle->get_plan_snapshot_id() );
+ $this->assertNull( $cycle->get_items_snapshot_id() );
+ }
+
+ public function test_expected_total_preserves_a_large_realistic_amount(): void {
+ // Money normalizes through a float (the same path as core's wc_format_decimal),
+ // which is exact within double precision - so a realistic large amount with full
+ // 8-decimal scale round-trips unchanged.
+ $cycle = $this->make_pending( array( 'expected_total' => '12345.12345678' ) );
+
+ $this->assertSame( '12345.12345678', $cycle->get_expected_total() );
+ }
+
+ public function test_create_can_build_a_billed_cycle_directly(): void {
+ // The checkout signup cycle is created directly billed (the origin order is paid).
+ $cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ }
+
+ public function test_create_requires_a_contract_id(): void {
+ $this->expectException( DomainException::class );
+
+ Cycle::create(
+ array(
+ 'sequence_no' => 1,
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ )
+ );
+ }
+
+ public function test_create_accepts_the_known_billing_kind(): void {
+ $cycle = $this->make_pending( array( 'kind' => Cycle::KIND_BILLING ) );
+
+ $this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+ }
+
+ public function test_create_accepts_an_unknown_but_well_formed_kind(): void {
+ // kind is known-but-extensible: a third party may introduce its own kind,
+ // so an unrecognized non-empty kind is accepted rather than rejected.
+ $cycle = $this->make_pending( array( 'kind' => 'shipping' ) );
+
+ $this->assertSame( 'shipping', $cycle->get_kind() );
+ }
+
+ public function test_create_rejects_an_empty_kind(): void {
+ $this->expectException( DomainException::class );
+
+ $this->make_pending( array( 'kind' => '' ) );
+ }
+
+ public function test_create_rejects_a_non_positive_sequence_no(): void {
+ $this->expectException( DomainException::class );
+
+ $this->make_pending( array( 'sequence_no' => 0 ) );
+ }
+
+ public function test_create_rejects_a_non_positive_count(): void {
+ $this->expectException( DomainException::class );
+
+ $this->make_pending( array( 'count' => 0 ) );
+ }
+
+ public function test_create_allows_a_null_count_for_a_non_counting_cycle(): void {
+ // A non-counting cycle (for example a future trial period) carries no
+ // chargeable number, so count is null and no charge idempotency anchor applies.
+ $cycle = $this->make_pending( array( 'count' => null ) );
+
+ $this->assertNull( $cycle->get_count() );
+ }
+
+ public function test_create_rejects_an_invalid_status(): void {
+ $this->expectException( DomainException::class );
+
+ $this->make_pending( array( 'status' => 'nonsense' ) );
+ }
+
+ public function test_create_carries_order_id_and_owner(): void {
+ $cycle = $this->make_pending(
+ array(
+ 'order_id' => 123,
+ 'extension_slug' => 'acme-subs',
+ )
+ );
+
+ $this->assertSame( 123, $cycle->get_order_id() );
+ $this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
+ }
+
+ public function test_holds_snapshot_vos_when_provided(): void {
+ $plan = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+ $items = ItemsSnapshot::from_items( array( array( 'product_id' => 42 ) ) );
+
+ $cycle = $this->make_pending(
+ array(
+ 'plan_snapshot' => $plan,
+ 'items_snapshot' => $items,
+ )
+ );
+
+ $this->assertSame( $plan, $cycle->get_plan_snapshot() );
+ $this->assertSame( $items, $cycle->get_items_snapshot() );
+ }
+
+ public function test_status_changes_go_through_cycle_status(): void {
+ $cycle = $this->make_pending();
+
+ $cycle->set_status( CycleStatus::billed() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ }
+
+ public function test_status_change_rejects_an_illegal_transition(): void {
+ $cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+
+ // billed is terminal, so it cannot move to failed.
+ $this->expectException( DomainException::class );
+ $cycle->set_status( CycleStatus::failed() );
+ }
+
+ public function test_setting_the_same_status_is_a_no_op(): void {
+ $cycle = $this->make_pending();
+
+ $cycle->set_status( CycleStatus::pending() );
+
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::pending() ) );
+ }
+
+ public function test_period_boundaries_are_frozen_at_construction(): void {
+ // The Cycle is an immutable record: there is no setter for the period
+ // boundaries, they are fixed by create()/from_storage(). Asserting the
+ // public surface via reflection means re-introducing a setter trips this.
+ $reflection = new \ReflectionClass( Cycle::class );
+
+ $this->assertFalse( $reflection->hasMethod( 'set_starts_at_gmt' ) );
+ $this->assertFalse( $reflection->hasMethod( 'set_ends_at_gmt' ) );
+ }
+
+ public function test_plan_snapshot_id_is_stamped_write_once(): void {
+ $cycle = $this->make_pending();
+
+ $cycle->set_plan_snapshot_id( 11 );
+ $cycle->set_items_snapshot_id( 22 );
+
+ $this->assertSame( 11, $cycle->get_plan_snapshot_id() );
+ $this->assertSame( 22, $cycle->get_items_snapshot_id() );
+ }
+
+ public function test_plan_snapshot_id_cannot_be_re_pointed(): void {
+ $cycle = $this->make_pending();
+ $cycle->set_plan_snapshot_id( 11 );
+
+ $this->expectException( DomainException::class );
+ $cycle->set_plan_snapshot_id( 99 );
+ }
+
+ public function test_items_snapshot_id_cannot_be_re_pointed(): void {
+ $cycle = $this->make_pending();
+ $cycle->set_items_snapshot_id( 22 );
+
+ $this->expectException( DomainException::class );
+ $cycle->set_items_snapshot_id( 99 );
+ }
+
+ public function test_plan_snapshot_vo_is_attached_write_once(): void {
+ $cycle = $this->make_pending();
+
+ $plan = PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) );
+ $cycle->set_plan_snapshot( $plan );
+
+ $this->assertSame( $plan, $cycle->get_plan_snapshot() );
+ }
+
+ public function test_plan_snapshot_vo_cannot_be_replaced(): void {
+ $cycle = $this->make_pending( array( 'plan_snapshot' => PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) ) ) );
+
+ $this->expectException( DomainException::class );
+ $cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
+ }
+
+ public function test_items_snapshot_vo_cannot_be_replaced(): void {
+ $cycle = $this->make_pending( array( 'items_snapshot' => ItemsSnapshot::from_items( array() ) ) );
+
+ $this->expectException( DomainException::class );
+ $cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
+ }
+
+ public function test_reason_can_be_annotated_on_a_pending_cycle(): void {
+ $cycle = $this->make_pending();
+
+ $cycle->set_reason( 'flagged for review' );
+
+ $this->assertSame( 'flagged for review', $cycle->get_reason() );
+ }
+
+ public function test_reason_can_be_annotated_when_cancelling_a_pending_cycle(): void {
+ $cycle = $this->make_pending();
+
+ $cycle->set_status( CycleStatus::cancelled() );
+ $cycle->set_reason( 'customer requested cancellation' );
+
+ $this->assertSame( 'customer requested cancellation', $cycle->get_reason() );
+ }
+
+ public function test_reason_can_be_annotated_on_a_billed_cycle(): void {
+ // `reason` is one of the few mutable fields: any cycle may carry one.
+ $cycle = $this->make_pending( array( 'status' => CycleStatus::billed() ) );
+
+ $cycle->set_reason( 'settled after retry' );
+
+ $this->assertSame( 'settled after retry', $cycle->get_reason() );
+ }
+
+ public function test_reason_can_be_annotated_on_a_failed_cycle(): void {
+ $cycle = $this->make_pending();
+ $cycle->set_status( CycleStatus::failed() );
+
+ $cycle->set_reason( 'gateway declined the charge' );
+
+ $this->assertSame( 'gateway declined the charge', $cycle->get_reason() );
+ }
+
+ public function test_from_storage_hydrates_a_persisted_cycle(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 5,
+ 'contract_id' => 7,
+ 'sequence_no' => 2,
+ 'count' => 2,
+ 'kind' => Cycle::KIND_BILLING,
+ 'status' => 'billed',
+ 'reason' => null,
+ 'starts_at_gmt' => '2026-03-01 00:00:00',
+ 'ends_at_gmt' => '2026-04-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ 'plan_snapshot_id' => 11,
+ 'items_snapshot_id' => 22,
+ 'order_id' => 123,
+ 'extension_slug' => 'acme-subs',
+ )
+ );
+
+ $this->assertSame( 5, $cycle->get_id() );
+ $this->assertSame( 7, $cycle->get_contract_id() );
+ $this->assertSame( 2, $cycle->get_sequence_no() );
+ $this->assertSame( 2, $cycle->get_count() );
+ $this->assertTrue( $cycle->get_status()->equals( CycleStatus::billed() ) );
+ $this->assertSame( 11, $cycle->get_plan_snapshot_id() );
+ $this->assertSame( 22, $cycle->get_items_snapshot_id() );
+ $this->assertSame( 123, $cycle->get_order_id() );
+ $this->assertSame( 'acme-subs', $cycle->get_extension_slug() );
+ }
+
+ public function test_from_storage_cycle_plan_snapshot_ref_is_write_once(): void {
+ // A cycle hydrated from storage already carries non-null snapshot refs through
+ // the constructor; the write-once guard must still reject re-pointing them.
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 5,
+ 'contract_id' => 7,
+ 'sequence_no' => 2,
+ 'count' => 2,
+ 'kind' => Cycle::KIND_BILLING,
+ 'status' => 'billed',
+ 'reason' => null,
+ 'starts_at_gmt' => '2026-03-01 00:00:00',
+ 'ends_at_gmt' => '2026-04-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ 'plan_snapshot_id' => 11,
+ 'items_snapshot_id' => 22,
+ 'order_id' => 123,
+ 'extension_slug' => 'acme-subs',
+ )
+ );
+
+ $this->expectException( DomainException::class );
+ $cycle->set_plan_snapshot_id( 99 );
+ }
+
+ public function test_from_storage_cycle_items_snapshot_ref_is_write_once(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 5,
+ 'contract_id' => 7,
+ 'sequence_no' => 2,
+ 'count' => 2,
+ 'kind' => Cycle::KIND_BILLING,
+ 'status' => 'billed',
+ 'reason' => null,
+ 'starts_at_gmt' => '2026-03-01 00:00:00',
+ 'ends_at_gmt' => '2026-04-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ 'plan_snapshot_id' => 11,
+ 'items_snapshot_id' => 22,
+ 'order_id' => 123,
+ 'extension_slug' => 'acme-subs',
+ )
+ );
+
+ $this->expectException( DomainException::class );
+ $cycle->set_items_snapshot_id( 99 );
+ }
+
+ public function test_from_storage_hydrates_a_non_counting_cycle(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 6,
+ 'contract_id' => 7,
+ 'sequence_no' => 1,
+ 'count' => null,
+ 'kind' => Cycle::KIND_BILLING,
+ 'status' => 'pending',
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'expected_total' => '0',
+ 'currency' => 'USD',
+ )
+ );
+
+ $this->assertNull( $cycle->get_count() );
+ }
+
+ public function test_to_storage_excludes_id_and_serializes_the_row(): void {
+ $cycle = $this->make_pending(
+ array(
+ 'order_id' => 123,
+ 'extension_slug' => 'acme-subs',
+ 'plan_snapshot_id' => 11,
+ )
+ );
+ $cycle->set_items_snapshot_id( 22 );
+
+ $row = $cycle->to_storage();
+
+ $this->assertArrayNotHasKey( 'id', $row );
+ $this->assertArrayNotHasKey( 'chain_id', $row );
+ $this->assertSame( 7, $row['contract_id'] );
+ $this->assertSame( 1, $row['sequence_no'] );
+ $this->assertSame( 1, $row['count'] );
+ $this->assertSame( Cycle::KIND_BILLING, $row['kind'] );
+ $this->assertSame( 'pending', $row['status'] );
+ $this->assertSame( '20.00000000', $row['expected_total'] );
+ $this->assertSame( 11, $row['plan_snapshot_id'] );
+ $this->assertSame( 22, $row['items_snapshot_id'] );
+ $this->assertSame( 123, $row['order_id'] );
+ $this->assertSame( 'acme-subs', $row['extension_slug'] );
+ }
+
+ public function test_to_storage_serializes_the_status_as_its_string_value(): void {
+ $row = $this->make_pending()->to_storage();
+
+ // The status column stores the plain string value; the typed CycleStatus is
+ // an in-memory concern only.
+ $this->assertSame( 'pending', $row['status'] );
+ }
+
+ public function test_sequence_no_can_be_reassigned_after_construction(): void {
+ $cycle = $this->make_pending( array( 'sequence_no' => 1 ) );
+
+ $cycle->set_sequence_no( 4 );
+
+ $this->assertSame( 4, $cycle->get_sequence_no() );
+ }
+
+ public function test_set_sequence_no_rejects_a_non_positive_value(): void {
+ $cycle = $this->make_pending();
+
+ $this->expectException( DomainException::class );
+ $cycle->set_sequence_no( 0 );
+ }
+
+ public function test_id_can_be_stamped_after_persistence(): void {
+ $cycle = $this->make_pending();
+
+ $cycle->set_id( 5 );
+
+ $this->assertSame( 5, $cycle->get_id() );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
new file mode 100644
index 00000000000..fc62bb0a0cf
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Unit tests for ItemsSnapshot.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot
+ */
+class ItemsSnapshotTest extends TestCase {
+
+ /**
+ * A representative two-item set.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ private function sample_items(): array {
+ return array(
+ array(
+ 'product_id' => 42,
+ 'quantity' => 2,
+ 'total' => '20.00',
+ ),
+ array(
+ 'product_id' => 7,
+ 'quantity' => 1,
+ 'total' => '5.00',
+ ),
+ );
+ }
+
+ public function test_schema_version_defaults_to_one(): void {
+ $snapshot = ItemsSnapshot::from_items( $this->sample_items() );
+
+ $this->assertSame( 1, $snapshot->get_schema_version() );
+ }
+
+ public function test_schema_version_is_carried(): void {
+ $snapshot = ItemsSnapshot::from_items( $this->sample_items(), 2 );
+
+ $this->assertSame( 2, $snapshot->get_schema_version() );
+ }
+
+ public function test_round_trips_through_items(): void {
+ $items = $this->sample_items();
+ $snapshot = ItemsSnapshot::from_items( $items );
+
+ $this->assertSame( $items, $snapshot->get_items() );
+ }
+
+ public function test_to_payload_preserves_the_item_list_order(): void {
+ $snapshot = ItemsSnapshot::from_items( $this->sample_items() );
+
+ $payload = $snapshot->to_payload();
+
+ // The item list order is meaningful and preserved; no per-item key
+ // reordering happens (dedup is copy-forward, not a canonical hash).
+ $this->assertSame( 42, $payload[0]['product_id'] );
+ $this->assertSame( 7, $payload[1]['product_id'] );
+ }
+
+ public function test_from_payload_reconstructs_an_equal_snapshot(): void {
+ $snapshot = ItemsSnapshot::from_items( $this->sample_items(), 2 );
+
+ $restored = ItemsSnapshot::from_payload( $snapshot->to_payload(), $snapshot->get_schema_version() );
+
+ $this->assertSame( $snapshot->to_payload(), $restored->to_payload() );
+ $this->assertSame( 2, $restored->get_schema_version() );
+ }
+
+ public function test_empty_item_set_is_allowed(): void {
+ $snapshot = ItemsSnapshot::from_items( array() );
+
+ $this->assertSame( array(), $snapshot->get_items() );
+ $this->assertSame( array(), $snapshot->to_payload() );
+ }
+
+ public function test_negative_schema_version_throws(): void {
+ $this->expectException( DomainException::class );
+
+ ItemsSnapshot::from_items( $this->sample_items(), -1 );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
new file mode 100644
index 00000000000..d41799d4ee2
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Unit tests for PlanSnapshot.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
+
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot
+ */
+class PlanSnapshotTest extends TestCase {
+
+ public function test_schema_version_defaults_to_one(): void {
+ $snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+
+ $this->assertSame( 1, $snapshot->get_schema_version() );
+ }
+
+ public function test_schema_version_is_carried(): void {
+ $snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 3 );
+
+ $this->assertSame( 3, $snapshot->get_schema_version() );
+ }
+
+ /**
+ * @testdox get_selling_plan_id returns the plan id when present.
+ */
+ public function test_get_selling_plan_id_returns_the_id_when_present(): void {
+ $snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+
+ $this->assertSame( 7, $snapshot->get_selling_plan_id() );
+ }
+
+ /**
+ * @testdox get_selling_plan_id returns null when the key is absent.
+ */
+ public function test_get_selling_plan_id_is_null_when_absent(): void {
+ $snapshot = PlanSnapshot::from_array( array( 'name' => 'Monthly box' ) );
+
+ $this->assertNull( $snapshot->get_selling_plan_id() );
+ }
+
+ public function test_round_trips_through_array(): void {
+ $data = array(
+ 'selling_plan_id' => 7,
+ 'name' => 'Monthly box',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ );
+ $snapshot = PlanSnapshot::from_array( $data );
+
+ $this->assertSame( $data, $snapshot->to_array() );
+ }
+
+ public function test_to_payload_returns_the_plan_terms_in_their_original_order(): void {
+ $data = array(
+ 'name' => 'Monthly box',
+ 'selling_plan_id' => 7,
+ );
+ $snapshot = PlanSnapshot::from_array( $data );
+
+ // The payload is the data as given; no key reordering happens here. Equal
+ // consecutive plans dedupe by copy-forward in storage, not by a canonical hash.
+ $this->assertSame( $data, $snapshot->to_payload() );
+ }
+
+ public function test_from_payload_reconstructs_an_equal_snapshot(): void {
+ $snapshot = PlanSnapshot::from_array(
+ array(
+ 'selling_plan_id' => 7,
+ 'name' => 'Monthly box',
+ ),
+ 2
+ );
+
+ $restored = PlanSnapshot::from_payload( $snapshot->to_payload(), $snapshot->get_schema_version() );
+
+ $this->assertSame( $snapshot->to_payload(), $restored->to_payload() );
+ $this->assertSame( 2, $restored->get_schema_version() );
+ }
+
+ public function test_negative_schema_version_throws(): void {
+ $this->expectException( DomainException::class );
+
+ PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
+ }
+}