Commit 1b7abd534ef for woocommerce
commit 1b7abd534effb90484ec38e3a949352fe751133e
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Wed Oct 7 19:55:21 2026 +0200
Subscriptions engine: explicit contract creation and view reads through the facade (#69447)
Create contracts from explicit fields and read them as views through Api\Contracts
- Add Api\Contracts as the single contract facade: create(), update() (writes only the passed columns; null when the contract is gone), add_cycle() and WordPress-style multi-value meta, plus all contract reads (get, find_by_origin_order, list, count, count_by_status, item_counts, list_for_customer with a status filter, get_for_customer, get_cycles)
- Return immutable ContractView and CycleView objects from every facade read and write; item and address children use the write shape
- Accept WooCommerce-style argument arrays; unknown keys raise _doing_it_wrong() and are ignored, shape checks live in an internal ArgumentValidator and invariants on the entities
- Only extension_slug is required to create a contract; status defaults to a new draft status, and customer, currency, plan and start become nullable (schema 2.5.0, with HPOS-style contract meta indexes)
- Assign the next cycle position on append; the cycle count is left to the caller
- Move meta out of the contract entity and row write, and fail item and address writes loudly when the database write fails
- Remove ContractFactory, insert_with_origin_cycle(), the parent order linkage and OrderRef; the engine no longer reads orders or writes order meta to create a contract, and creates no snapshots
- Keep only the interim lifecycle and renewal methods on Api\Subscriptions, marked for removal
- Rename ScalarCoercion to Coercion and share the array coercion helpers
- Let the interim renewal flow park contracts without a currency, renew contracts without a customer as guest orders, and cancel drafts; bound the related-orders query to the requested page
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-explicit-contract-creation b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-explicit-contract-creation
new file mode 100644
index 00000000000..2fe25465413
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-explicit-contract-creation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Subscriptions engine package is not released yet; explicit contract creation and view reads through the facade need no changelog entry.
+
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
new file mode 100644
index 00000000000..e14b0b36028
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -0,0 +1,546 @@
+<?php
+/**
+ * Contracts - the engine's public contract facade (reads and writes).
+ *
+ * Extensions create and progressively build contracts from explicit argument arrays:
+ * the engine records the facts it is given and decides nothing about them. Any caller
+ * may read or write any contract (authorization is the caller's concern). Reads return
+ * read-only views ({@see ContractView}, {@see CycleView}). The engine opens no
+ * transaction and keeps no cache, so a caller may wrap several calls in its own
+ * transaction. No hooks fire.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use DomainException;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Public contract facade: reads and writes.
+ *
+ * Final and static-only: a stateless entry point, not an extension seam.
+ */
+final class Contracts {
+
+ /**
+ * Keys accepted by {@see self::update()}, as a key map.
+ *
+ * @var array<string, true>
+ */
+ private const CONTRACT_KEYS = array(
+ 'customer_id' => true,
+ 'currency' => true,
+ 'selling_plan_id' => true,
+ 'origin_order_id' => true,
+ 'status' => true,
+ 'payment_method' => true,
+ 'payment_method_title' => true,
+ 'payment_token_id' => true,
+ 'start_gmt' => true,
+ 'next_payment_gmt' => true,
+ 'last_payment_gmt' => true,
+ 'last_attempt_gmt' => true,
+ 'trial_end_gmt' => true,
+ 'end_gmt' => true,
+ 'schedule_source' => true,
+ 'billing_total' => true,
+ 'discount_total' => true,
+ 'shipping_total' => true,
+ 'tax_total' => true,
+ 'items' => true,
+ 'addresses' => true,
+ );
+
+ /**
+ * Keys accepted by {@see self::create()}: the update keys plus the create-only `extension_slug`.
+ *
+ * @var array<string, true>
+ */
+ private const CREATE_KEYS = array( 'extension_slug' => true ) + self::CONTRACT_KEYS;
+
+ /**
+ * Keys accepted by {@see self::add_cycle()}, as a key map.
+ *
+ * @var array<string, true>
+ */
+ private const CYCLE_KEYS = array(
+ 'status' => true,
+ 'kind' => true,
+ 'sequence_no' => true,
+ 'count' => true,
+ 'starts_at_gmt' => true,
+ 'ends_at_gmt' => true,
+ 'expected_total' => true,
+ 'currency' => true,
+ 'order_id' => true,
+ );
+
+ /**
+ * Create a contract from explicit fields.
+ *
+ * Only `extension_slug` is required; the status defaults to `draft`. Dates accept a
+ * `DateTimeInterface` or a GMT `Y-m-d H:i:s` string; money accepts numbers or numeric
+ * strings, and a non-null money value requires a currency. `items` is a list of item
+ * rows; `addresses` is keyed `billing` / `shipping`. Unknown keys, also inside item rows
+ * and addresses, raise a `_doing_it_wrong()` notice and are ignored. The contract row,
+ * items and addresses are separate writes and the engine opens no transaction: wrap the
+ * call in one when a failed write must leave nothing behind.
+ *
+ * @param array<string, mixed> $args Contract fields: `extension_slug` (required, the owning
+ * extension), `status` (a registered contract status,
+ * default `draft`), and any of {@see self::CONTRACT_KEYS}.
+ * @return ContractView The new contract, built from the written fields (no re-read): items
+ * and addresses as given.
+ * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid.
+ */
+ public static function create( array $args ): ContractView {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CREATE_KEYS );
+ $extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+ unset( $filtered_args['extension_slug'] );
+
+ try {
+ $contract = Contract::create( array( 'extension_slug' => $extension_slug ) );
+ self::apply( $contract, $filtered_args );
+ } catch ( DomainException $e ) {
+ throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ }
+
+ ( new ContractRepository() )->insert( $contract );
+
+ return ContractView::from_contract( $contract, true );
+ }
+
+ /**
+ * Write the given fields to an existing contract.
+ *
+ * Takes the keys of {@see self::create()} except `extension_slug`. Only the columns of
+ * the present keys are written, so fields a concurrent writer changed in between keep
+ * its values; `items` and `addresses` replace the whole set; `null` clears a
+ * nullable field (and resets a money field to 0). Unknown keys (`extension_slug`
+ * included) raise a `_doing_it_wrong()` notice and are ignored. Items and addresses are
+ * replaced delete-then-insert and the engine opens no transaction: wrap the call in one
+ * when a failed replacement must keep the previous rows.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, mixed> $args Fields to write.
+ * @return ContractView|null The row as read before the write plus the written fields (a column
+ * another writer changed meanwhile may be stale here, not in storage);
+ * null when the contract does not exist (also when it is deleted
+ * before the write).
+ * @throws InvalidArgumentException If a value is invalid.
+ */
+ public static function update( int $contract_id, array $args ): ?ContractView {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );
+
+ $repository = new ContractRepository();
+ $contract = $repository->find( $contract_id );
+ if ( null === $contract ) {
+ return null;
+ }
+
+ try {
+ self::apply( $contract, $filtered_args );
+ } catch ( DomainException $e ) {
+ throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ }
+
+ $fields = array_keys( $filtered_args );
+ if ( array() === $fields ) {
+ return ContractView::from_contract( $contract, true );
+ }
+
+ if ( ! $repository->update_fields( $contract, $fields ) ) {
+ return null;
+ }
+
+ return ContractView::from_contract( $contract, true );
+ }
+
+ /**
+ * Append a cycle to a contract's chain `(contract_id, kind)`.
+ *
+ * The first public form of the cycle append tool: append-if-absent on the chain's
+ * unique positions. `starts_at_gmt`, `ends_at_gmt` and `currency` are required.
+ * `status` (a registered cycle status) defaults to `pending`; `kind` defaults to
+ * `billing`; an absent or null `sequence_no` is assigned on append as the head's plus one;
+ * `count` is the caller's chargeable number (absent or null for a non-counting cycle;
+ * unique within the chain); `expected_total` defaults to 0; `order_id` is optional. The contract is not
+ * read: appending to an unknown contract id is a caller error. Unknown keys raise a
+ * `_doing_it_wrong()` notice and are ignored.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, mixed> $args Cycle fields.
+ * @return CycleView The appended cycle.
+ * @throws InvalidArgumentException If a required key is missing or a value is invalid.
+ * @throws DomainException If the chain position or count is already taken.
+ */
+ public static function add_cycle( int $contract_id, array $args ): CycleView {
+ $filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );
+
+ $cycle_args = array(
+ 'contract_id' => $contract_id,
+ 'kind' => $filtered_args['kind'] ?? null,
+ 'sequence_no' => ArgumentValidator::validate_nullable_id( 'sequence_no', $filtered_args['sequence_no'] ?? null ),
+ 'count' => ArgumentValidator::validate_nullable_id( 'count', $filtered_args['count'] ?? null ),
+ 'status' => $filtered_args['status'] ?? null,
+ 'starts_at_gmt' => ArgumentValidator::validate_nullable_date( 'starts_at_gmt', $filtered_args['starts_at_gmt'] ?? null ),
+ 'ends_at_gmt' => ArgumentValidator::validate_nullable_date( 'ends_at_gmt', $filtered_args['ends_at_gmt'] ?? null ),
+ 'expected_total' => ArgumentValidator::validate_money( 'expected_total', $filtered_args['expected_total'] ?? null ),
+ 'currency' => ArgumentValidator::validate_currency( $filtered_args['currency'] ?? null ),
+ 'order_id' => ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
+ );
+
+ try {
+ $cycle = Cycle::create( $cycle_args );
+ } catch ( DomainException $e ) {
+ throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+ }
+
+ try {
+ ( new ContractRepository() )->append_cycle( $cycle, null );
+ } catch ( DuplicateCycleException $e ) {
+ throw new DomainException( 'Contracts: the cycle position or count already exists.' );
+ }
+
+ return CycleView::from_cycle( $cycle );
+ }
+
+ /**
+ * Add a meta value to a contract, like `add_post_meta()`. A key may hold several values.
+ * The contract is not looked up: meta for an unknown contract id is a caller error.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @param mixed $value Meta value; serialized when not scalar.
+ * @param bool $unique When true, add nothing if the key already exists. Advisory: checked
+ * before the insert with no unique index, so concurrent adds can both write.
+ * @return int|null The meta row id; null when `$unique` and the key exists.
+ * @throws InvalidArgumentException If `$key` is empty.
+ */
+ public static function add_meta( int $contract_id, string $key, $value, bool $unique = false ): ?int {
+ return ( new ContractRepository() )->add_meta( $contract_id, $key, $value, $unique );
+ }
+
+ /**
+ * Update a contract's meta values for `$key`, like `update_post_meta()`: adds the key
+ * when absent, else rewrites every value, or only the values equal to `$prev_value`.
+ * The absent-key check runs before the write with no unique index, so it is not a lock.
+ * The contract is not looked up: meta for an unknown contract id is a caller error.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @param mixed $value New value; serialized when not scalar.
+ * @param mixed $prev_value Only update values equal to this; null updates all. Any other
+ * value ('' and false included) matches literally.
+ * @return bool True when a value was added or changed; false when nothing changed.
+ * @throws InvalidArgumentException If `$key` is empty.
+ */
+ public static function update_meta( int $contract_id, string $key, $value, $prev_value = null ): bool {
+ return ( new ContractRepository() )->update_meta( $contract_id, $key, $value, $prev_value );
+ }
+
+ /**
+ * Delete a contract's meta values for `$key`, like `delete_post_meta()`.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @param mixed $value Only delete values equal to this; null deletes every value for the key.
+ * Any other value ('' and false included) matches literally.
+ * @return bool True when at least one value was deleted.
+ * @throws InvalidArgumentException If `$key` is empty.
+ */
+ public static function delete_meta( int $contract_id, string $key, $value = null ): bool {
+ return ( new ContractRepository() )->delete_meta( $contract_id, $key, $value );
+ }
+
+ /**
+ * Read contract meta (WordPress `get_post_meta()` semantics), oldest value first.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key; empty for every key.
+ * @param bool $single With a key: return the first value only.
+ * @return mixed Empty key: values grouped by key. Key + `$single`: the first value, or ''
+ * when absent. Key only: the list of values (`[]` when absent).
+ */
+ public static function get_meta( int $contract_id, string $key = '', bool $single = false ) {
+ return ( new ContractRepository() )->get_meta( $contract_id, $key, $single );
+ }
+
+ /**
+ * Fetch a contract by id, with its items and addresses.
+ *
+ * @param int $contract_id Contract id.
+ * @return ContractView|null The contract, or null when none exists.
+ */
+ public static function get( int $contract_id ): ?ContractView {
+ return self::view( ( new ContractRepository() )->find( $contract_id ), true );
+ }
+
+ /**
+ * The contracts created from an origin order, oldest first (children not loaded).
+ *
+ * @param int $order_id Origin order id.
+ * @return array<int, ContractView>
+ */
+ public static function find_by_origin_order( int $order_id ): array {
+ return self::views( ( new ContractRepository() )->find_by_origin_order( $order_id ) );
+ }
+
+ /**
+ * List contracts for an admin list screen - newest first by default, or
+ * filtered / sorted / paged / searched via a WooCommerce-style args array (cf.
+ * `wc_get_orders()`). The status + search filter matches {@see self::count()}, so a page
+ * and its total describe the same set.
+ *
+ * @param array<string, mixed> $args {
+ * Optional. Query args.
+ *
+ * @type int $limit Maximum contracts to return. Default 20.
+ * @type int $offset Contracts to skip (for paging). Default 0.
+ * @type string $status Filter to one status ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}); ignored when empty or invalid.
+ * @type string $orderby One of id, next_payment, total, start; default id.
+ * @type string $order ASC or DESC (case-insensitive); default DESC.
+ * @type string $search Numeric term matches contract id or origin order id; text term matches the owning customer.
+ * }
+ * @return array<int, ContractView> Contracts in the requested order (children not loaded).
+ */
+ public static function list( array $args = array() ): array {
+ return self::views( ( new ContractRepository() )->query( $args ) );
+ }
+
+ /**
+ * The contract count per status - the read behind an admin list's status views bar.
+ * Keyed by every {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus} value (absent statuses are 0); the `All` total
+ * is the caller's `array_sum()`. Independent of any search or paging.
+ *
+ * @return array<string, int> Status => count, every known status present.
+ */
+ public static function count_by_status(): array {
+ return ( new ContractRepository() )->count_by_status();
+ }
+
+ /**
+ * The number of contracts matching a list filter - the total behind a list view's
+ * pagination. Honours the SAME status + search args as {@see self::list()} and ignores
+ * paging / sort.
+ *
+ * @param array<string, mixed> $args Query args (only `status` and `search` are read).
+ * @return int The matching contract count.
+ */
+ public static function count( array $args = array() ): int {
+ return ( new ContractRepository() )->count( $args );
+ }
+
+ /**
+ * The line-item count for a page of contracts - the read behind an admin list's
+ * "Items" column. One grouped scan over the given ids, returned as a map keyed by
+ * every requested id (ids with no items are 0), so a list renders an items count
+ * per row without a per-row query. Ids are de-duplicated and int-cast.
+ *
+ * @param array<int, int> $contract_ids Contract ids to count items for.
+ * @return array<int, int> Contract id => line-item count, one entry per requested id.
+ */
+ public static function item_counts( array $contract_ids ): array {
+ return ( new ContractRepository() )->count_items_by_contract( $contract_ids );
+ }
+
+ /**
+ * List a single customer's contracts, newest first - the customer
+ * portal's owner-scoped list read.
+ *
+ * Owner-scoped by construction: the customer id is supplied by the caller (the
+ * authenticated user at the REST boundary), never inferred, so it never returns
+ * another customer's contracts. Each view projects the stored contract fields (items
+ * and addresses not loaded); a caller needing plan terms resolves `selling_plan_id`
+ * through {@see SellingPlans}.
+ *
+ * The status filter applies before paging, so a page holds `$limit` matching contracts.
+ *
+ * @param int $customer_id Owning customer id.
+ * @param int $limit Maximum contracts to return.
+ * @param int $offset Contracts to skip (for paging).
+ * @param array<string, mixed> $args {
+ * Optional. Query args.
+ *
+ * @type string|string[] $status One status or a list of them ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus});
+ * unregistered values are dropped, and the filter is ignored when none remain.
+ * }
+ * @return array<int, ContractView> The customer's contracts, newest first.
+ */
+ public static function list_for_customer( int $customer_id, int $limit = 20, int $offset = 0, array $args = array() ): array {
+ return self::views(
+ ( new ContractRepository() )->find_by_customer_id(
+ $customer_id,
+ array(
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'status' => $args['status'] ?? array(),
+ )
+ )
+ );
+ }
+
+ /**
+ * Fetch a contract a customer owns - the customer portal's ownership-checked read.
+ *
+ * Returns null for BOTH an unknown id AND a contract owned by another customer (the
+ * asymmetric not-found rule), so a caller cannot probe for the existence of a
+ * contract it does not own.
+ *
+ * The returned view projects the stored contract fields with items and addresses; a
+ * caller needing plan terms resolves `selling_plan_id` through {@see SellingPlans}.
+ *
+ * @param int $contract_id Contract id.
+ * @param int $customer_id Customer that must own the contract.
+ * @return ContractView|null The contract when owned by `$customer_id`, else null.
+ * @phpstan-impure
+ */
+ public static function get_for_customer( int $contract_id, int $customer_id ): ?ContractView {
+ return self::view( ( new ContractRepository() )->find_for_customer( $contract_id, $customer_id ), true );
+ }
+
+ /**
+ * Fetch a window of the contract's billing cycles, newest first.
+ *
+ * @param int $contract_id Contract id.
+ * @param int $limit Maximum cycles to return.
+ * @return array<int, CycleView> Cycles newest first.
+ */
+ public static function get_cycles( int $contract_id, int $limit = 20 ): array {
+ return array_map(
+ static function ( Cycle $cycle ): CycleView {
+ return CycleView::from_cycle( $cycle );
+ },
+ ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit )
+ );
+ }
+
+ // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
+ /**
+ * Validate the caller's field shapes and apply them to a contract through its setters,
+ * which enforce the entity invariants. Nothing is written to storage; an invalid value
+ * throws before any write.
+ *
+ * @param Contract $contract Contract to change.
+ * @param array<string, mixed> $args Caller fields (known keys only, no `extension_slug`).
+ * @throws InvalidArgumentException If a value has the wrong shape.
+ * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+ */
+ private static function apply( Contract $contract, array $args ): void {
+ $instrument = $contract->get_payment_instrument();
+ $token_id = $instrument->get_token_id();
+ $gateway = $instrument->get_gateway();
+ $title = $instrument->get_title();
+
+ foreach ( $args as $key => $value ) {
+ switch ( $key ) {
+ case 'customer_id':
+ $contract->set_customer_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
+ break;
+ case 'selling_plan_id':
+ $contract->set_selling_plan_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
+ break;
+ case 'origin_order_id':
+ $contract->set_origin_order_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
+ break;
+ case 'currency':
+ $contract->set_currency( ArgumentValidator::validate_currency( $value ) );
+ break;
+ case 'status':
+ $contract->set_status( ArgumentValidator::validate_string( $key, $value ) );
+ break;
+ case 'schedule_source':
+ $contract->set_schedule_source( ArgumentValidator::validate_string( $key, $value ) );
+ break;
+ case 'start_gmt':
+ $contract->set_start_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+ break;
+ case 'next_payment_gmt':
+ $contract->set_next_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+ break;
+ case 'last_payment_gmt':
+ $contract->set_last_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+ break;
+ case 'last_attempt_gmt':
+ $contract->set_last_attempt_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+ break;
+ case 'trial_end_gmt':
+ $contract->set_trial_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+ break;
+ case 'end_gmt':
+ $contract->set_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
+ break;
+ case 'billing_total':
+ $contract->set_billing_total( ArgumentValidator::validate_money( $key, $value ) );
+ break;
+ case 'discount_total':
+ $contract->set_discount_total( ArgumentValidator::validate_money( $key, $value ) );
+ break;
+ case 'shipping_total':
+ $contract->set_shipping_total( ArgumentValidator::validate_money( $key, $value ) );
+ break;
+ case 'tax_total':
+ $contract->set_tax_total( ArgumentValidator::validate_money( $key, $value ) );
+ break;
+ case 'items':
+ $contract->set_items( ArgumentValidator::validate_contract_items( $value, self::class ) );
+ break;
+ case 'addresses':
+ $contract->set_addresses( ArgumentValidator::validate_contract_addresses( $value, self::class ) );
+ break;
+ case 'payment_token_id':
+ $token_id = ArgumentValidator::validate_nullable_id( $key, $value );
+ break;
+ case 'payment_method':
+ $gateway = ArgumentValidator::validate_nullable_string( $key, $value );
+ break;
+ case 'payment_method_title':
+ $title = ArgumentValidator::validate_nullable_string( $key, $value );
+ break;
+ }
+ }
+
+ $contract->set_payment_instrument( new InstrumentRef( $token_id, $gateway, $title ) );
+ $contract->assert_money_has_currency();
+ }
+ // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
+
+ /**
+ * A view of `$contract`, or null.
+ *
+ * @param Contract|null $contract Contract, or null.
+ * @param bool $with_children Whether the read loaded items and addresses.
+ */
+ private static function view( ?Contract $contract, bool $with_children ): ?ContractView {
+ return null === $contract ? null : ContractView::from_contract( $contract, $with_children );
+ }
+
+ /**
+ * Views of row-only contracts (children not loaded).
+ *
+ * @param array<int, Contract> $contracts Contracts.
+ * @return array<int, ContractView>
+ */
+ private static function views( array $contracts ): array {
+ return array_map(
+ static function ( Contract $contract ): ContractView {
+ return ContractView::from_contract( $contract, false );
+ },
+ $contracts
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
index 07b30b5cea4..1b3f84a85ef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/ContractsController.php
@@ -45,9 +45,10 @@ use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
defined( 'ABSPATH' ) || exit;
@@ -205,7 +206,7 @@ final class ContractsController extends WP_REST_Controller {
// schema, so it arrives as a real bool; the coercion path covers a caller
// invoking the method directly with a raw value.
$param = $request->get_param( 'at_period_end' );
- $at_period_end = is_bool( $param ) ? $param : rest_sanitize_boolean( ScalarCoercion::coerce_string( $param, 'true' ) );
+ $at_period_end = is_bool( $param ) ? $param : rest_sanitize_boolean( Coercion::coerce_string( $param, 'true' ) );
return $this->run_action(
$request,
@@ -225,7 +226,7 @@ final class ContractsController extends WP_REST_Controller {
* Domain values only - the id and the resulting status slug - never labels,
* formatted values, or other presentation: consumers own their view shaping.
*
- * @param Contract $item Contract.
+ * @param ContractView $item Contract view.
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
@@ -286,13 +287,13 @@ final class ContractsController extends WP_REST_Controller {
* @return WP_REST_Response|WP_Error
*/
private function run_action( WP_REST_Request $request, callable $action ) {
- $contract_id = ScalarCoercion::coerce_int( $request->get_param( 'id' ) );
+ $contract_id = Coercion::coerce_int( $request->get_param( 'id' ) );
$customer_id = get_current_user_id();
// Guard ownership before acting: the facade's ownership-checked read returns
// null for an unknown id and a foreign-owned contract alike, so both map to
// the same 404 (anti-IDOR).
- if ( null === Subscriptions::get_for_customer( $contract_id, $customer_id ) ) {
+ if ( null === Contracts::get_for_customer( $contract_id, $customer_id ) ) {
return $this->not_found_error();
}
@@ -314,7 +315,7 @@ final class ContractsController extends WP_REST_Controller {
// Re-read for the resulting status. The action already succeeded, so a row
// vanishing here is a server-side inconsistency - a 500, not a not-found.
- $refreshed = Subscriptions::get_for_customer( $contract_id, $customer_id );
+ $refreshed = Contracts::get_for_customer( $contract_id, $customer_id );
if ( null === $refreshed ) {
return new WP_Error(
'woocommerce_subscriptions_engine_refresh_failed',
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
index 569ffb4f68f..8e3ef301141 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -10,7 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
@@ -203,7 +203,7 @@ final class PlansController extends WP_REST_Controller {
return $extension_slugs;
}
- $page = max( 1, ScalarCoercion::coerce_int( $request->get_param( 'page' ), 1 ) );
+ $page = max( 1, Coercion::coerce_int( $request->get_param( 'page' ), 1 ) );
$per_page = $this->resolve_per_page( $request );
$args = array(
'limit' => $per_page,
@@ -251,7 +251,7 @@ final class PlansController extends WP_REST_Controller {
return $extension_slug;
}
- $plan = $this->plan_repository->find( ScalarCoercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
+ $plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
if ( ! $plan instanceof Plan ) {
return $this->not_found_error();
}
@@ -292,7 +292,7 @@ final class PlansController extends WP_REST_Controller {
'pricing_policy' => $this->pricing_policy_from_param( $request->get_param( 'pricing_policy' ), null ),
'category' => $this->string_param( $request, 'category', Plan::DEFAULT_CATEGORY ),
'status' => $this->string_param( $request, 'status', Plan::STATUS_ACTIVE ),
- 'sort_order' => ScalarCoercion::coerce_int( $request->get_param( 'sort_order' ) ),
+ 'sort_order' => Coercion::coerce_int( $request->get_param( 'sort_order' ) ),
'extension_slug' => $extension_slug,
)
);
@@ -325,7 +325,7 @@ final class PlansController extends WP_REST_Controller {
return $extension_slug;
}
- $plan = $this->plan_repository->find( ScalarCoercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
+ $plan = $this->plan_repository->find( Coercion::coerce_int( $request->get_param( 'id' ) ), $extension_slug );
if ( ! $plan instanceof Plan ) {
return $this->not_found_error();
}
@@ -367,7 +367,7 @@ final class PlansController extends WP_REST_Controller {
}
if ( $request->has_param( 'sort_order' ) ) {
- $plan->set_sort_order( ScalarCoercion::coerce_int( $request->get_param( 'sort_order' ) ) );
+ $plan->set_sort_order( Coercion::coerce_int( $request->get_param( 'sort_order' ) ) );
}
$errors = $this->validate_with_owner( $plan, $extension_slug );
@@ -409,7 +409,7 @@ final class PlansController extends WP_REST_Controller {
$sort_order_by_id = array();
$response_ids = array();
foreach ( array_values( $ids ) as $index => $raw_id ) {
- $id = ScalarCoercion::coerce_nullable_int( $raw_id );
+ $id = Coercion::coerce_nullable_int( $raw_id );
if ( null === $id || $id <= 0 ) {
return $this->invalid_error( __( 'ids must contain only positive integers.', 'woocommerce-subscriptions-engine' ) );
}
@@ -451,7 +451,7 @@ final class PlansController extends WP_REST_Controller {
'pricing_policy' => $item->get_pricing_policy(),
);
- $context = ScalarCoercion::coerce_string( $request->get_param( 'context' ), 'view' );
+ $context = Coercion::coerce_string( $request->get_param( 'context' ), 'view' );
$context = '' !== $context ? $context : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
@@ -587,7 +587,7 @@ final class PlansController extends WP_REST_Controller {
* @param WP_REST_Request $request Request.
*/
private function resolve_per_page( WP_REST_Request $request ): int {
- $value = ScalarCoercion::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
+ $value = Coercion::coerce_int( $request->get_param( 'per_page' ), self::DEFAULT_PER_PAGE );
if ( $value < 1 ) {
return self::DEFAULT_PER_PAGE;
}
@@ -689,7 +689,7 @@ final class PlansController extends WP_REST_Controller {
if ( null === $raw ) {
return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
}
- $raw_string = trim( ScalarCoercion::coerce_string( $raw ) );
+ $raw_string = trim( Coercion::coerce_string( $raw ) );
if ( '' === $raw_string ) {
return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
}
@@ -722,7 +722,7 @@ final class PlansController extends WP_REST_Controller {
if ( null === $raw ) {
return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
}
- $raw_string = trim( ScalarCoercion::coerce_string( $raw ) );
+ $raw_string = trim( Coercion::coerce_string( $raw ) );
if ( '' === $raw_string ) {
return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
}
@@ -751,7 +751,7 @@ final class PlansController extends WP_REST_Controller {
* @param string $fallback Fallback.
*/
private function string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
- return sanitize_text_field( ScalarCoercion::coerce_string( $request->get_param( $key ), $fallback ) );
+ return sanitize_text_field( Coercion::coerce_string( $request->get_param( $key ), $fallback ) );
}
/**
@@ -761,7 +761,7 @@ final class PlansController extends WP_REST_Controller {
* @param string $key Param key.
*/
private function nullable_string_param( WP_REST_Request $request, string $key ): ?string {
- $value = ScalarCoercion::coerce_nullable_string( $request->get_param( $key ) );
+ $value = Coercion::coerce_nullable_string( $request->get_param( $key ) );
if ( null === $value || '' === $value ) {
return null;
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
index ac65d90b134..df5232ab987 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Subscriptions.php
@@ -1,17 +1,9 @@
<?php
/**
- * Subscriptions - the engine's public consumer facade.
+ * Subscriptions - the engine's interim lifecycle and renewal facade.
*
- * The one surface consumers (a host plugin's admin UI, tests) import to read and act
- * on subscriptions: read the contract and its cycle history, cancel, and run a renewal
- * now. It hides the internal `Core\` / `Integration\` collaborators (the repository,
- * the renewal engine) behind a stable boundary, so the internals stay refactorable.
- *
- * Interim return types: it returns the core entities ({@see Contract}, {@see Cycle})
- * and `WC_Order` directly for now; richer read-model views are a planned follow-up, so
- * consumers also reference those types until the views land. `Api\` is the public
- * surface, not a third internal zone - the two-zone (Core/Integration) model still
- * describes the internals.
+ * Cancel, hold, reactivate, renew now, and read a contract's related orders. It hides
+ * the internal `Core\` / `Integration\` collaborators behind a stable boundary.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api
*/
@@ -21,8 +13,6 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\RelatedOrders;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
@@ -35,137 +25,13 @@ defined( 'ABSPATH' ) || exit;
/**
* Public subscriptions facade.
*
+ * Interim: removed once lifecycle flows and renewals move to extensions. Add no new
+ * methods here; contract reads and writes live in {@see Contracts}.
+ *
* Final and static-only: a stateless entry point, not an extension seam.
*/
final class Subscriptions {
- /**
- * Fetch a subscription contract by id, with its frozen plan terms hydrated.
- *
- * The returned contract carries its plan snapshot ({@see Contract::get_plan_snapshot()}),
- * so a consumer reads the billing cadence straight off the snapshot - no live
- * plan-repository join.
- *
- * @param int $contract_id Contract id.
- * @return Contract|null The contract, or null when none exists.
- */
- public static function get( int $contract_id ): ?Contract {
- return ( new ContractRepository() )->find( $contract_id );
- }
-
- /**
- * List subscription contracts for an admin list screen - newest first by default, or
- * filtered / sorted / paged / searched via a WooCommerce-style args array (cf.
- * `wc_get_orders()`). The status + search filter matches {@see self::count()}, so a page
- * and its total describe the same set.
- *
- * @param array<string, mixed> $args {
- * Optional. Query args.
- *
- * @type int $limit Maximum contracts to return. Default 20.
- * @type int $offset Contracts to skip (for paging). Default 0.
- * @type string $status Filter to one status ({@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}); ignored when empty or invalid.
- * @type string $orderby One of id, next_payment, total, start; default id.
- * @type string $order ASC or DESC (case-insensitive); default DESC.
- * @type string $search Numeric term matches contract id or origin order id; text term matches the owning customer.
- * }
- * @return array<int, Contract> Contracts in the requested order.
- */
- public static function list( array $args = array() ): array {
- return ( new ContractRepository() )->query( $args );
- }
-
- /**
- * The contract count per status - the read behind an admin list's status views bar.
- * Keyed by every {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus} value (absent statuses are 0); the `All` total
- * is the caller's `array_sum()`. Independent of any search or paging.
- *
- * @return array<string, int> Status => count, every known status present.
- */
- public static function count_by_status(): array {
- return ( new ContractRepository() )->count_by_status();
- }
-
- /**
- * The number of contracts matching a list filter - the total behind a list view's
- * pagination. Honours the SAME status + search args as {@see self::list()} and ignores
- * paging / sort.
- *
- * @param array<string, mixed> $args Query args (only `status` and `search` are read).
- * @return int The matching contract count.
- */
- public static function count( array $args = array() ): int {
- return ( new ContractRepository() )->count( $args );
- }
-
- /**
- * The line-item count for a page of contracts - the read behind an admin list's
- * "Items" column. One grouped scan over the given ids, returned as a map keyed by
- * every requested id (ids with no items are 0), so a list renders an items count
- * per row without a per-row query. Ids are de-duplicated and int-cast.
- *
- * @param array<int, int> $contract_ids Contract ids to count items for.
- * @return array<int, int> Contract id => line-item count, one entry per requested id.
- */
- public static function item_counts( array $contract_ids ): array {
- return ( new ContractRepository() )->count_items_by_contract( $contract_ids );
- }
-
- /**
- * List a single customer's subscription contracts, newest first - the customer
- * portal's owner-scoped list read.
- *
- * Owner-scoped by construction: the customer id is supplied by the caller (the
- * authenticated user at the REST boundary), never inferred, so it never returns
- * another customer's contracts. Returns interim {@see Contract} entities, each with its
- * frozen plan terms hydrated ({@see Contract::get_plan_snapshot()}) so a list row's
- * cadence is read off the snapshot.
- *
- * @param int $customer_id Owning customer id.
- * @param int $limit Maximum contracts to return.
- * @param int $offset Contracts to skip (for paging).
- * @return array<int, Contract> The customer's contracts, newest first.
- */
- public static function list_for_customer( int $customer_id, int $limit = 20, int $offset = 0 ): array {
- return ( new ContractRepository() )->find_by_customer_id(
- $customer_id,
- array(
- 'limit' => $limit,
- 'offset' => $offset,
- )
- );
- }
-
- /**
- * Fetch a contract a customer owns - the customer portal's ownership-checked read.
- *
- * Returns null for BOTH an unknown id AND a contract owned by another customer (the
- * asymmetric not-found rule), so a caller cannot probe for the existence of a
- * contract it does not own.
- *
- * The returned contract carries its frozen plan terms ({@see Contract::get_plan_snapshot()}),
- * so the cadence is read off the snapshot with no live plan-repository join.
- *
- * @param int $contract_id Contract id.
- * @param int $customer_id Customer that must own the contract.
- * @return Contract|null The contract when owned by `$customer_id`, else null.
- * @phpstan-impure
- */
- public static function get_for_customer( int $contract_id, int $customer_id ): ?Contract {
- return ( new ContractRepository() )->find_for_customer( $contract_id, $customer_id );
- }
-
- /**
- * Fetch a window of the contract's billing cycle history, newest first.
- *
- * @param int $contract_id Contract id.
- * @param int $limit Maximum cycles to return.
- * @return array<int, Cycle> Cycles newest first.
- */
- public static function get_history( int $contract_id, int $limit = 20 ): array {
- return ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit );
- }
-
/**
* The orders related to a contract (the origin order, plus renewals / switches /
* resubscribes), newest first - the portal detail's related-orders read kept
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
new file mode 100644
index 00000000000..cdc4b749c56
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/ContractView.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * ContractView - a read-only view of a contract at the `Api\` boundary.
+ *
+ * Consumers read contracts through this view instead of the Core entity. Getters may
+ * be added, never removed. Children (`items`, `addresses`) take the shape the
+ * contracts write facade accepts; they are null when the read did not load them (list
+ * reads) and an array when it did.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Immutable contract view.
+ */
+final class ContractView {
+
+ /**
+ * Contract row values keyed by field name.
+ *
+ * @var array{id: int, status: string, extension_slug: ?string, customer_id: ?int, currency: ?string, selling_plan_id: ?int, origin_order_id: ?int, payment_method: ?string, payment_method_title: ?string, payment_token_id: ?int, start_gmt: ?string, next_payment_gmt: ?string, last_payment_gmt: ?string, last_attempt_gmt: ?string, trial_end_gmt: ?string, end_gmt: ?string, billing_total: string, discount_total: string, shipping_total: string, tax_total: string, schedule_source: string}
+ */
+ private $fields;
+
+ /**
+ * Line items, or null when not loaded.
+ *
+ * @var array<int, array<string, mixed>>|null
+ */
+ private $items;
+
+ /**
+ * Addresses keyed by type, or null when not loaded.
+ *
+ * @var array<string, array<string, mixed>>|null
+ */
+ private $addresses;
+
+ /**
+ * Use {@see self::from_contract()}.
+ */
+ private function __construct() {
+ }
+
+ /**
+ * Build a view of a stored contract.
+ *
+ * @internal Built by the engine `Api\` facades only.
+ *
+ * @param Contract $contract Stored contract.
+ * @param bool $with_children Whether the read loaded items and addresses.
+ */
+ public static function from_contract( Contract $contract, bool $with_children ): self {
+ $instrument = $contract->get_payment_instrument();
+
+ $view = new self();
+ $view->fields = array(
+ 'id' => (int) $contract->get_id(),
+ 'status' => $contract->get_status(),
+ 'extension_slug' => $contract->get_extension_slug(),
+ '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(),
+ 'payment_method' => $instrument->get_gateway(),
+ 'payment_method_title' => $instrument->get_title(),
+ 'payment_token_id' => $instrument->get_token_id(),
+ 'start_gmt' => $contract->get_start_gmt(),
+ 'next_payment_gmt' => $contract->get_next_payment_gmt(),
+ 'last_payment_gmt' => $contract->get_last_payment_gmt(),
+ 'last_attempt_gmt' => $contract->get_last_attempt_gmt(),
+ 'trial_end_gmt' => $contract->get_trial_end_gmt(),
+ 'end_gmt' => $contract->get_end_gmt(),
+ 'billing_total' => $contract->get_billing_total(),
+ 'discount_total' => $contract->get_discount_total(),
+ 'shipping_total' => $contract->get_shipping_total(),
+ 'tax_total' => $contract->get_tax_total(),
+ 'schedule_source' => $contract->get_schedule_source(),
+ );
+
+ $view->items = $with_children ? array_map( array( self::class, 'item' ), $contract->get_items() ) : null;
+ $view->addresses = $with_children ? array_map( array( self::class, 'address' ), $contract->get_addresses() ) : null;
+
+ return $view;
+ }
+
+ /**
+ * Project a stored item row onto the item write fields; `taxes` decoded to an array.
+ *
+ * @param array<string, mixed> $row Stored item row.
+ * @return array<string, mixed>
+ */
+ private static function item( array $row ): array {
+ $item = array();
+ foreach ( Contract::ITEM_FIELDS as $field ) {
+ $item[ $field ] = $row[ $field ] ?? null;
+ }
+
+ foreach ( array( 'product_id', 'variation_id' ) as $field ) {
+ $item[ $field ] = is_numeric( $item[ $field ] ) ? (int) $item[ $field ] : null;
+ }
+
+ $taxes = is_string( $item['taxes'] ) ? json_decode( $item['taxes'], true ) : $item['taxes'];
+ $item['taxes'] = is_array( $taxes ) ? $taxes : null;
+
+ return $item;
+ }
+
+ /**
+ * Project a stored address row onto the address write fields.
+ *
+ * @param array<string, mixed> $row Stored address row.
+ * @return array<string, mixed>
+ */
+ private static function address( array $row ): array {
+ $address = array();
+ foreach ( Contract::ADDRESS_FIELDS as $field ) {
+ $address[ $field ] = $row[ $field ] ?? null;
+ }
+
+ return $address;
+ }
+
+ /**
+ * Contract id.
+ */
+ public function get_id(): int {
+ return $this->fields['id'];
+ }
+
+ /**
+ * Contract status slug.
+ */
+ public function get_status(): string {
+ return $this->fields['status'];
+ }
+
+ /**
+ * Owning extension slug, or null.
+ */
+ public function get_extension_slug(): ?string {
+ return $this->fields['extension_slug'];
+ }
+
+ /**
+ * Customer id, or null.
+ */
+ public function get_customer_id(): ?int {
+ return $this->fields['customer_id'];
+ }
+
+ /**
+ * ISO-4217 currency code, or null.
+ */
+ public function get_currency(): ?string {
+ return $this->fields['currency'];
+ }
+
+ /**
+ * Selling plan id, or null.
+ */
+ public function get_selling_plan_id(): ?int {
+ return $this->fields['selling_plan_id'];
+ }
+
+ /**
+ * Origin order id, or null.
+ */
+ public function get_origin_order_id(): ?int {
+ return $this->fields['origin_order_id'];
+ }
+
+ /**
+ * Payment gateway id, or null.
+ */
+ public function get_payment_method(): ?string {
+ return $this->fields['payment_method'];
+ }
+
+ /**
+ * Payment method title, or null.
+ */
+ public function get_payment_method_title(): ?string {
+ return $this->fields['payment_method_title'];
+ }
+
+ /**
+ * Payment token id, or null.
+ */
+ public function get_payment_token_id(): ?int {
+ return $this->fields['payment_token_id'];
+ }
+
+ /**
+ * Start (GMT `Y-m-d H:i:s`), or null.
+ */
+ public function get_start_gmt(): ?string {
+ return $this->fields['start_gmt'];
+ }
+
+ /**
+ * Next-due moment (GMT), or null.
+ */
+ public function get_next_payment_gmt(): ?string {
+ return $this->fields['next_payment_gmt'];
+ }
+
+ /**
+ * Last successful payment (GMT), or null.
+ */
+ public function get_last_payment_gmt(): ?string {
+ return $this->fields['last_payment_gmt'];
+ }
+
+ /**
+ * Last charge attempt (GMT), or null.
+ */
+ public function get_last_attempt_gmt(): ?string {
+ return $this->fields['last_attempt_gmt'];
+ }
+
+ /**
+ * Trial end (GMT), or null.
+ */
+ public function get_trial_end_gmt(): ?string {
+ return $this->fields['trial_end_gmt'];
+ }
+
+ /**
+ * End (GMT), or null.
+ */
+ public function get_end_gmt(): ?string {
+ return $this->fields['end_gmt'];
+ }
+
+ /**
+ * Billing total (decimal string).
+ */
+ public function get_billing_total(): string {
+ return $this->fields['billing_total'];
+ }
+
+ /**
+ * Discount total (decimal string).
+ */
+ public function get_discount_total(): string {
+ return $this->fields['discount_total'];
+ }
+
+ /**
+ * Shipping total (decimal string).
+ */
+ public function get_shipping_total(): string {
+ return $this->fields['shipping_total'];
+ }
+
+ /**
+ * Tax total (decimal string).
+ */
+ public function get_tax_total(): string {
+ return $this->fields['tax_total'];
+ }
+
+ /**
+ * Who runs renewals: `primitive` or `gateway`.
+ */
+ public function get_schedule_source(): string {
+ return $this->fields['schedule_source'];
+ }
+
+ /**
+ * Line items, or null when the read did not load them.
+ *
+ * @return array<int, array<string, mixed>>|null
+ */
+ public function get_items(): ?array {
+ return $this->items;
+ }
+
+ /**
+ * Addresses keyed by type (`billing` / `shipping`), or null when the read did not load them.
+ *
+ * @return array<string, array<string, mixed>>|null
+ */
+ public function get_addresses(): ?array {
+ return $this->addresses;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
new file mode 100644
index 00000000000..b00aaf7bdc9
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/View/CycleView.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * CycleView - a read-only view of a cycle at the `Api\` boundary.
+ *
+ * Consumers read cycles through this view instead of the Core entity. Getters may be
+ * added, never removed.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api\View
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api\View;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Immutable cycle view.
+ */
+final class CycleView {
+
+ /**
+ * Cycle id.
+ *
+ * @var int
+ */
+ private $id;
+
+ /**
+ * Contract id.
+ *
+ * @var int
+ */
+ private $contract_id;
+
+ /**
+ * Chain kind (e.g. `billing`).
+ *
+ * @var string
+ */
+ private $kind;
+
+ /**
+ * Position in the chain.
+ *
+ * @var int
+ */
+ private $sequence_no;
+
+ /**
+ * Charge count, or null for a non-counting cycle.
+ *
+ * @var int|null
+ */
+ private $count;
+
+ /**
+ * Cycle status slug.
+ *
+ * @var string
+ */
+ private $status;
+
+ /**
+ * Period start (GMT `Y-m-d H:i:s`).
+ *
+ * @var string
+ */
+ private $starts_at_gmt;
+
+ /**
+ * Period end (GMT `Y-m-d H:i:s`).
+ *
+ * @var string
+ */
+ private $ends_at_gmt;
+
+ /**
+ * Expected total (decimal string).
+ *
+ * @var string
+ */
+ private $expected_total;
+
+ /**
+ * ISO-4217 currency code.
+ *
+ * @var string
+ */
+ private $currency;
+
+ /**
+ * Linked order id, or null.
+ *
+ * @var int|null
+ */
+ private $order_id;
+
+ /**
+ * Use {@see self::from_cycle()}.
+ */
+ private function __construct() {
+ }
+
+ /**
+ * Build a view of a stored cycle.
+ *
+ * @internal Built by the engine `Api\` facades only.
+ *
+ * @param Cycle $cycle Stored cycle.
+ */
+ public static function from_cycle( Cycle $cycle ): self {
+ $view = new self();
+ $view->id = (int) $cycle->get_id();
+ $view->contract_id = $cycle->get_contract_id();
+ $view->kind = $cycle->get_kind();
+ $view->sequence_no = $cycle->get_sequence_no();
+ $view->count = $cycle->get_count();
+ $view->status = $cycle->get_status()->get_value();
+ $view->starts_at_gmt = $cycle->get_starts_at_gmt();
+ $view->ends_at_gmt = $cycle->get_ends_at_gmt();
+ $view->expected_total = $cycle->get_expected_total();
+ $view->currency = $cycle->get_currency();
+ $view->order_id = $cycle->get_order_id();
+
+ return $view;
+ }
+
+ /**
+ * Cycle id.
+ */
+ public function get_id(): int {
+ return $this->id;
+ }
+
+ /**
+ * Contract id.
+ */
+ public function get_contract_id(): int {
+ return $this->contract_id;
+ }
+
+ /**
+ * Chain kind (e.g. `billing`).
+ */
+ public function get_kind(): string {
+ return $this->kind;
+ }
+
+ /**
+ * Position in the chain.
+ */
+ public function get_sequence_no(): int {
+ return $this->sequence_no;
+ }
+
+ /**
+ * Charge count, or null for a non-counting cycle.
+ */
+ public function get_count(): ?int {
+ return $this->count;
+ }
+
+ /**
+ * Cycle status slug.
+ */
+ public function get_status(): string {
+ return $this->status;
+ }
+
+ /**
+ * Period start (GMT `Y-m-d H:i:s`).
+ */
+ public function get_starts_at_gmt(): string {
+ return $this->starts_at_gmt;
+ }
+
+ /**
+ * Period end (GMT `Y-m-d H:i:s`).
+ */
+ public function get_ends_at_gmt(): string {
+ return $this->ends_at_gmt;
+ }
+
+ /**
+ * Expected total (decimal string).
+ */
+ public function get_expected_total(): string {
+ return $this->expected_total;
+ }
+
+ /**
+ * ISO-4217 currency code.
+ */
+ public function get_currency(): string {
+ return $this->currency;
+ }
+
+ /**
+ * Linked order id, or null.
+ */
+ public function get_order_id(): ?int {
+ return $this->order_id;
+ }
+}
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 aca53862b66..69680920ab5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -13,10 +13,10 @@
*
* 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}.
+ * `origin_order_id` is an optional extension fact. Customer, currency, selling plan and
+ * start are optional until the extension supplies them; a new contract defaults to
+ * `draft`. 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
*/
@@ -27,7 +27,7 @@ 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\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -47,6 +47,20 @@ final class Contract {
public const ADDRESS_BILLING = 'billing';
public const ADDRESS_SHIPPING = 'shipping';
+ /**
+ * Fields of a line-item row.
+ *
+ * @var array<int, string>
+ */
+ public const ITEM_FIELDS = array( 'item_name', 'item_type', 'product_id', 'variation_id', 'quantity', 'subtotal', 'total', 'taxes' );
+
+ /**
+ * Fields of an address.
+ *
+ * @var array<int, string>
+ */
+ public const ADDRESS_FIELDS = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone' );
+
/**
* Contract id, or null before it is persisted.
*
@@ -62,29 +76,28 @@ final class Contract {
private $status;
/**
- * Owning customer id.
+ * Owning customer id, or null.
*
- * @var int
+ * @var int|null
*/
private $customer_id;
/**
- * ISO-4217 currency code, locked at creation.
+ * ISO-4217 currency code, or null.
*
- * @var string
+ * @var string|null
*/
private $currency;
/**
- * Foreign key to the selling plan.
+ * Selling plan id, or null.
*
- * @var int
+ * @var int|null
*/
private $selling_plan_id;
/**
- * Origin order id, or null for a manual contract. Equals cycle 1's `order_id`
- * for a checkout contract.
+ * Origin order id, or null when the extension recorded none.
*
* @var int|null
*/
@@ -119,9 +132,9 @@ final class Contract {
private $payment_token_id;
/**
- * When the contract goes (or went) active. GMT string.
+ * When the contract goes (or went) active, or null. GMT string.
*
- * @var string
+ * @var string|null
*/
private $start_gmt;
@@ -234,13 +247,6 @@ final class Contract {
*/
private $addresses;
- /**
- * Contract meta as key => value.
- *
- * @var array<string, string>
- */
- private $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.
@@ -248,37 +254,36 @@ final class Contract {
* @param array<string, mixed> $data Raw attributes keyed by property name.
*/
private function __construct( array $data ) {
- $this->id = ScalarCoercion::coerce_nullable_int( $data['id'] ?? null );
- $this->status = ScalarCoercion::coerce_string( $data['status'] ?? null, ContractStatus::ACTIVE );
- $this->customer_id = ScalarCoercion::coerce_int( $data['customer_id'] ?? null );
- $this->currency = ScalarCoercion::coerce_string( $data['currency'] ?? null );
- $this->selling_plan_id = ScalarCoercion::coerce_int( $data['selling_plan_id'] ?? null );
- $this->origin_order_id = ScalarCoercion::coerce_nullable_int( $data['origin_order_id'] ?? null );
- $this->extension_slug = ScalarCoercion::coerce_nullable_string( $data['extension_slug'] ?? null );
- $this->payment_method = ScalarCoercion::coerce_nullable_string( $data['payment_method'] ?? null );
- $this->payment_method_title = ScalarCoercion::coerce_nullable_string( $data['payment_method_title'] ?? null );
- $this->payment_token_id = ScalarCoercion::coerce_nullable_int( $data['payment_token_id'] ?? null );
- $this->start_gmt = ScalarCoercion::coerce_string( $data['start_gmt'] ?? null );
- $this->next_payment_gmt = ScalarCoercion::coerce_nullable_string( $data['next_payment_gmt'] ?? null );
- $this->plan_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
- $this->items_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+ $this->id = Coercion::coerce_nullable_int( $data['id'] ?? null );
+ $this->status = Coercion::coerce_string( $data['status'] ?? null, ContractStatus::DRAFT );
+ $this->customer_id = Coercion::coerce_nullable_int( $data['customer_id'] ?? null );
+ $this->currency = Coercion::coerce_nullable_string( $data['currency'] ?? null );
+ $this->selling_plan_id = Coercion::coerce_nullable_int( $data['selling_plan_id'] ?? null );
+ $this->origin_order_id = Coercion::coerce_nullable_int( $data['origin_order_id'] ?? null );
+ $this->extension_slug = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+ $this->payment_method = Coercion::coerce_nullable_string( $data['payment_method'] ?? null );
+ $this->payment_method_title = Coercion::coerce_nullable_string( $data['payment_method_title'] ?? null );
+ $this->payment_token_id = Coercion::coerce_nullable_int( $data['payment_token_id'] ?? null );
+ $this->start_gmt = Coercion::coerce_nullable_string( $data['start_gmt'] ?? null );
+ $this->next_payment_gmt = Coercion::coerce_nullable_string( $data['next_payment_gmt'] ?? null );
+ $this->plan_snapshot_id = Coercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+ $this->items_snapshot_id = Coercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
$this->billing_total = MoneyScale::normalize_money( $data['billing_total'] ?? '0' );
$this->discount_total = MoneyScale::normalize_money( $data['discount_total'] ?? '0' );
$this->shipping_total = MoneyScale::normalize_money( $data['shipping_total'] ?? '0' );
$this->tax_total = MoneyScale::normalize_money( $data['tax_total'] ?? '0' );
- $this->last_payment_gmt = ScalarCoercion::coerce_nullable_string( $data['last_payment_gmt'] ?? null );
- $this->last_attempt_gmt = ScalarCoercion::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
- $this->trial_end_gmt = ScalarCoercion::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
- $this->end_gmt = ScalarCoercion::coerce_nullable_string( $data['end_gmt'] ?? null );
- $this->schedule_source = ScalarCoercion::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 );
+ $this->last_payment_gmt = Coercion::coerce_nullable_string( $data['last_payment_gmt'] ?? null );
+ $this->last_attempt_gmt = Coercion::coerce_nullable_string( $data['last_attempt_gmt'] ?? null );
+ $this->trial_end_gmt = Coercion::coerce_nullable_string( $data['trial_end_gmt'] ?? null );
+ $this->end_gmt = Coercion::coerce_nullable_string( $data['end_gmt'] ?? null );
+ $this->schedule_source = Coercion::coerce_string( $data['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
+ $this->items = Coercion::coerce_list_of_arrays( $data['items'] ?? null );
+ $this->addresses = Coercion::coerce_map_of_arrays( $data['addresses'] ?? null );
$this->plan_snapshot = ( $data['plan_snapshot'] ?? null ) instanceof PlanSnapshot ? $data['plan_snapshot'] : null;
}
/**
- * Build a new, unsaved contract.
+ * Build a new, unsaved contract. `extension_slug` is required.
*
* @param array<string, mixed> $args Contract attributes.
* @throws DomainException If the contract attributes are not valid.
@@ -289,15 +294,32 @@ final class Contract {
$contract = new self( $args );
- if ( ! ContractStatus::is_registered( $contract->status ) ) {
- throw new DomainException( sprintf( 'Contract: invalid status "%s".', $contract->status ) );
- }
+ self::assert_status( $contract->status );
+ self::assert_extension_slug( $contract->extension_slug );
+ self::assert_schedule_source( $contract->schedule_source );
+ $contract->assert_money_has_currency();
- 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 ) );
+ return $contract;
+ }
+
+ /**
+ * Refuse money without a currency: a contract with no currency must have every total at zero.
+ * Cross-field, so callers that change several fields check it once after the last change.
+ * Checked against the state the caller holds: the engine opens no transaction or lock, so
+ * concurrent writers to the currency and totals of one contract coordinate themselves.
+ *
+ * @throws DomainException If a total is non-zero while the currency is unset.
+ */
+ public function assert_money_has_currency(): void {
+ if ( null !== $this->currency ) {
+ return;
}
- return $contract;
+ foreach ( array( $this->billing_total, $this->discount_total, $this->shipping_total, $this->tax_total ) as $total ) {
+ if ( 0.0 !== (float) $total ) {
+ throw new DomainException( 'Contract: money totals require a currency.' );
+ }
+ }
}
/**
@@ -305,22 +327,20 @@ final class Contract {
*
* The frozen plan terms ride second, ahead of the child rows: a contract without
* its plan is pretty pointless, so the snapshot is hydrated on the same footing as
- * items / addresses / meta rather than through a separate mutation step.
+ * items / addresses rather than through a separate mutation step.
*
* @param array<string, mixed> $row Contract row.
* @param PlanSnapshot|null $plan_snapshot Frozen plan terms for the row's `plan_snapshot_id`, or null.
* @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.
*/
- public static function from_storage( array $row, ?PlanSnapshot $plan_snapshot = null, array $items = array(), array $addresses = array(), array $meta = array() ): self {
+ public static function from_storage( array $row, ?PlanSnapshot $plan_snapshot = null, array $items = array(), array $addresses = array() ): self {
$contract = new self(
array_merge(
$row,
array(
'items' => $items,
'addresses' => $addresses,
- 'meta' => $meta,
)
)
);
@@ -370,34 +390,59 @@ final class Contract {
return;
}
- if ( ! ContractStatus::is_registered( $status ) ) {
- throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
- }
+ self::assert_status( $status );
$this->status = $status;
}
/**
- * Owning customer id.
+ * Owning customer id, or null.
*/
- public function get_customer_id(): int {
+ public function get_customer_id(): ?int {
return $this->customer_id;
}
/**
- * ISO-4217 currency code.
+ * Set the owning customer id.
+ *
+ * @param int|null $customer_id Customer id, or null.
+ */
+ public function set_customer_id( ?int $customer_id ): void {
+ $this->customer_id = $customer_id;
+ }
+
+ /**
+ * ISO-4217 currency code, or null.
*/
- public function get_currency(): string {
+ public function get_currency(): ?string {
return $this->currency;
}
/**
- * Foreign key to the selling plan.
+ * Set the ISO-4217 currency code.
+ *
+ * @param string|null $currency Currency code, or null.
+ */
+ public function set_currency( ?string $currency ): void {
+ $this->currency = $currency;
+ }
+
+ /**
+ * Selling plan id, or null.
*/
- public function get_selling_plan_id(): int {
+ public function get_selling_plan_id(): ?int {
return $this->selling_plan_id;
}
+ /**
+ * Set the selling plan id.
+ *
+ * @param int|null $selling_plan_id Selling plan id, or null.
+ */
+ public function set_selling_plan_id( ?int $selling_plan_id ): void {
+ $this->selling_plan_id = $selling_plan_id;
+ }
+
/**
* Foreign key to the origin order, or null for a manual/admin contract.
*/
@@ -405,6 +450,15 @@ final class Contract {
return $this->origin_order_id;
}
+ /**
+ * Set the origin order id.
+ *
+ * @param int|null $origin_order_id Order id, or null.
+ */
+ public function set_origin_order_id( ?int $origin_order_id ): void {
+ $this->origin_order_id = $origin_order_id;
+ }
+
/**
* Owning extension slug, or null.
*/
@@ -625,12 +679,21 @@ final class Contract {
}
/**
- * Start timestamp (GMT string).
+ * Start timestamp (GMT string), or null.
*/
- public function get_start_gmt(): string {
+ public function get_start_gmt(): ?string {
return $this->start_gmt;
}
+ /**
+ * Set the start timestamp.
+ *
+ * @param string|null $start_gmt GMT string or null.
+ */
+ public function set_start_gmt( ?string $start_gmt ): void {
+ $this->start_gmt = $start_gmt;
+ }
+
/**
* Who runs renewals: 'primitive' or 'gateway'.
*/
@@ -638,6 +701,18 @@ final class Contract {
return $this->schedule_source;
}
+ /**
+ * Set who runs renewals.
+ *
+ * @param string $schedule_source 'primitive' or 'gateway'.
+ * @throws DomainException If `$schedule_source` is neither.
+ */
+ public function set_schedule_source( string $schedule_source ): void {
+ self::assert_schedule_source( $schedule_source );
+
+ $this->schedule_source = $schedule_source;
+ }
+
/**
* Line items.
*
@@ -648,39 +723,30 @@ final class Contract {
}
/**
- * Addresses keyed by type.
+ * Replace the line items.
*
- * @return array<string, array<string, mixed>>
+ * @param array<int|string, mixed> $items Item rows; non-array elements are skipped.
*/
- public function get_addresses(): array {
- return $this->addresses;
+ public function set_items( array $items ): void {
+ $this->items = Coercion::coerce_list_of_arrays( $items );
}
/**
- * Contract meta as key => value.
+ * Addresses keyed by type.
*
- * @return array<string, string>
+ * @return array<string, array<string, mixed>>
*/
- public function get_meta(): array {
- return $this->meta;
+ public function get_addresses(): array {
+ return $this->addresses;
}
/**
- * Set or remove one meta entry.
+ * Replace the addresses.
*
- * Meta is opaque key/value data; the repository's existing child sync
- * persists the map on save.
- *
- * @param string $key Meta key.
- * @param string|null $value Meta value, or null to remove the key.
+ * @param array<int|string, mixed> $addresses Address rows keyed by type; non-array elements are skipped.
*/
- public function set_meta( string $key, ?string $value ): void {
- if ( null === $value ) {
- unset( $this->meta[ $key ] );
- return;
- }
-
- $this->meta[ $key ] = $value;
+ public function set_addresses( array $addresses ): void {
+ $this->addresses = Coercion::coerce_map_of_arrays( $addresses );
}
/**
@@ -716,82 +782,38 @@ final class Contract {
}
/**
- * 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.
+ * Refuse a status that is not a registered contract status.
*
- * @param mixed $value Caller-supplied addresses.
- * @return array<string, array<string, mixed>>
+ * @param string $status Status to check.
+ * @throws DomainException If `$status` is not registered.
*/
- 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 );
- }
+ private static function assert_status( string $status ): void {
+ if ( ! ContractStatus::is_registered( $status ) ) {
+ throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
}
-
- return $map;
}
/**
- * Shape a caller-supplied value into the meta map (string => string). A non-array
- * yields an empty map.
+ * Refuse a missing or empty owning extension slug.
*
- * @param mixed $value Caller-supplied meta.
- * @return array<string, string>
+ * @param string|null $extension_slug Extension slug to check.
+ * @throws DomainException If `$extension_slug` is null or empty.
*/
- 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 ] = ScalarCoercion::coerce_string( $meta_value );
+ private static function assert_extension_slug( ?string $extension_slug ): void {
+ if ( null === $extension_slug || '' === $extension_slug ) {
+ throw new DomainException( 'Contract: extension_slug is required and must be a non-empty string.' );
}
-
- 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.
+ * Refuse a schedule source other than 'primitive' or 'gateway'.
*
- * @param array<int|string, mixed> $value Array to re-key.
- * @return array<string, mixed>
+ * @param string $schedule_source Schedule source to check.
+ * @throws DomainException If `$schedule_source` is neither.
*/
- private static function coerce_string_keyed( array $value ): array {
- $result = array();
- foreach ( $value as $key => $entry ) {
- $result[ (string) $key ] = $entry;
+ private static function assert_schedule_source( string $schedule_source ): void {
+ 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 ) );
}
-
- return $result;
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
index 1bb71a5430c..d4cdedc7d91 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/ContractStatus.php
@@ -23,6 +23,7 @@ defined( 'ABSPATH' ) || exit;
*/
final class ContractStatus {
+ public const DRAFT = 'draft';
public const ACTIVE = 'active';
public const ON_HOLD = 'on-hold';
public const PENDING_CANCELLATION = 'pending-cancellation';
@@ -36,6 +37,7 @@ final class ContractStatus {
*/
public static function get_defaults(): array {
return array(
+ self::DRAFT,
self::ACTIVE,
self::ON_HOLD,
self::PENDING_CANCELLATION,
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index b43a0ef51ce..9f1f3417e2b 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -17,8 +17,9 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
use DomainException;
+use LogicException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
@@ -49,9 +50,9 @@ final class Cycle {
private $contract_id;
/**
- * Position within the chain, monotonic from 1.
+ * Position within the chain, monotonic from 1, or null until the append assigns it.
*
- * @var int
+ * @var int|null
*/
private $sequence_no;
@@ -179,23 +180,23 @@ final class Cycle {
* @param array<string, mixed> $data Raw attributes keyed by property name.
*/
private function __construct( array $data ) {
- $this->id = ScalarCoercion::coerce_nullable_int( $data['id'] ?? null );
- $this->contract_id = ScalarCoercion::coerce_int( $data['contract_id'] ?? null );
- $this->sequence_no = ScalarCoercion::coerce_int( $data['sequence_no'] ?? null );
- $this->count = isset( $data['count'] ) ? ScalarCoercion::coerce_int( $data['count'] ) : null;
- $this->kind = ScalarCoercion::coerce_string( $data['kind'] ?? null, self::KIND_BILLING );
+ $this->id = Coercion::coerce_nullable_int( $data['id'] ?? null );
+ $this->contract_id = Coercion::coerce_int( $data['contract_id'] ?? null );
+ $this->sequence_no = isset( $data['sequence_no'] ) ? Coercion::coerce_int( $data['sequence_no'] ) : null;
+ $this->count = isset( $data['count'] ) ? Coercion::coerce_int( $data['count'] ) : null;
+ $this->kind = Coercion::coerce_string( $data['kind'] ?? null, self::KIND_BILLING );
$this->status = self::coerce_status( $data['status'] ?? null );
- $this->reason = ScalarCoercion::coerce_nullable_string( $data['reason'] ?? null );
- $this->starts_at_gmt = ScalarCoercion::coerce_string( $data['starts_at_gmt'] ?? null );
- $this->ends_at_gmt = ScalarCoercion::coerce_string( $data['ends_at_gmt'] ?? null );
+ $this->reason = Coercion::coerce_nullable_string( $data['reason'] ?? null );
+ $this->starts_at_gmt = Coercion::coerce_string( $data['starts_at_gmt'] ?? null );
+ $this->ends_at_gmt = Coercion::coerce_string( $data['ends_at_gmt'] ?? null );
$this->expected_total = MoneyScale::normalize_money( $data['expected_total'] ?? '0' );
- $this->currency = ScalarCoercion::coerce_string( $data['currency'] ?? null );
- $this->plan_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
- $this->items_snapshot_id = ScalarCoercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
- $this->order_id = ScalarCoercion::coerce_nullable_int( $data['order_id'] ?? null );
- $this->extension_slug = ScalarCoercion::coerce_nullable_string( $data['extension_slug'] ?? null );
- $this->claimed_until_gmt = ScalarCoercion::coerce_nullable_string( $data['claimed_until'] ?? null );
- $this->retry_at_gmt = ScalarCoercion::coerce_nullable_string( $data['retry_at'] ?? null );
+ $this->currency = Coercion::coerce_string( $data['currency'] ?? null );
+ $this->plan_snapshot_id = Coercion::coerce_nullable_int( $data['plan_snapshot_id'] ?? null );
+ $this->items_snapshot_id = Coercion::coerce_nullable_int( $data['items_snapshot_id'] ?? null );
+ $this->order_id = Coercion::coerce_nullable_int( $data['order_id'] ?? null );
+ $this->extension_slug = Coercion::coerce_nullable_string( $data['extension_slug'] ?? null );
+ $this->claimed_until_gmt = Coercion::coerce_nullable_string( $data['claimed_until'] ?? null );
+ $this->retry_at_gmt = Coercion::coerce_nullable_string( $data['retry_at'] ?? 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;
}
@@ -203,10 +204,10 @@ final class Cycle {
/**
* 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`,
+ * Required keys: `contract_id`, `starts_at_gmt`, `ends_at_gmt`, `expected_total`,
+ * `currency`. Optional: `sequence_no` (absent or null: the append assigns the chain's
+ * next position), `count` (absent or null for a non-counting cycle; the caller owns
+ * the numbering), `status` (defaults to `pending`), `kind` (defaults to billing), `reason`,
* `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`,
* `plan_snapshot`, `items_snapshot`.
*
@@ -214,21 +215,21 @@ final class Cycle {
* @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 );
- self::assert_registered_status( $cycle->status );
+ self::assert_contract_id( $cycle->contract_id );
+ self::assert_kind( $cycle->kind );
+ // Null is allowed here for auto-assign sequence_no during append operation.
+ if ( null !== $cycle->sequence_no ) {
+ self::assert_sequence_no( $cycle->sequence_no );
+ }
+ self::assert_period( $cycle->starts_at_gmt, $cycle->ends_at_gmt );
+ self::assert_currency( $cycle->currency );
+ self::assert_count( $cycle->count );
+ self::assert_status( $cycle->status );
return $cycle;
}
@@ -236,26 +237,16 @@ final class Cycle {
/**
* Hydrate from a stored row.
*
- * A well-formed stored status is kept verbatim, registered or not (registration is
- * checked only where a status is written), so a value written by a since-deactivated
- * extension round-trips unchanged.
+ * Stored values are not validated (invariants are checked only where a value is
+ * written), so a value written by a since-deactivated extension round-trips unchanged.
*
* @param array<string, mixed> $row Cycle row.
- * @throws DomainException If the stored kind or sequence_no is invalid, or the stored
- * status is not a well-formed status slug.
+ * @throws DomainException If the stored status is not a well-formed status slug.
*/
public static function from_storage( array $row ): self {
- $kind = ScalarCoercion::coerce_string( $row['kind'] ?? null, self::KIND_BILLING );
- self::assert_valid_kind( $kind );
-
- $sequence_no = ScalarCoercion::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 );
}
@@ -283,30 +274,40 @@ final class Cycle {
}
/**
- * 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.
+ * Position within the chain.
*
- * @param int $contract_id Owning contract id.
+ * @throws LogicException If the position is not assigned yet (a new cycle before its append).
*/
- public function set_contract_id( int $contract_id ): void {
- $this->contract_id = $contract_id;
+ public function get_sequence_no(): int {
+ if ( null === $this->sequence_no ) {
+ throw new LogicException( 'Cycle: sequence_no is assigned on append.' );
+ }
+
+ return $this->sequence_no;
}
/**
- * Position within the chain.
+ * Whether the append still has to assign the position within the chain.
+ *
+ * @internal Used by the repository's append.
*/
- public function get_sequence_no(): int {
- return $this->sequence_no;
+ public function awaits_sequence_no(): bool {
+ return null === $this->sequence_no;
}
/**
- * Set the position within the chain (assigned by the append path).
+ * Assign the position within the chain. Only while it is unassigned.
+ *
+ * @internal Used by the repository's append.
*
* @param int $sequence_no Position, monotonic from 1.
- * @throws DomainException If `$sequence_no` is not positive.
+ * @throws DomainException If the position is already assigned or `$sequence_no` is not positive.
*/
- public function set_sequence_no( int $sequence_no ): void {
- self::assert_valid_sequence_no( $sequence_no );
+ public function assign_sequence_no( int $sequence_no ): void {
+ if ( null !== $this->sequence_no ) {
+ throw new DomainException( 'Cycle: sequence_no is already assigned.' );
+ }
+ self::assert_sequence_no( $sequence_no );
$this->sequence_no = $sequence_no;
}
@@ -348,7 +349,7 @@ final class Cycle {
return;
}
- self::assert_registered_status( $status );
+ self::assert_status( $status );
$this->status = $status;
}
@@ -577,6 +578,18 @@ final class Cycle {
}
}
+ /**
+ * Refuse a missing (non-positive) owning contract id.
+ *
+ * @param int $contract_id Contract id to check.
+ * @throws DomainException If `$contract_id` is not positive.
+ */
+ private static function assert_contract_id( int $contract_id ): void {
+ if ( $contract_id < 1 ) {
+ throw new DomainException( 'Cycle: contract_id is required.' );
+ }
+ }
+
/**
* 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.
@@ -584,19 +597,44 @@ final class Cycle {
* @param string $kind Kind to validate.
* @throws DomainException If `$kind` is empty.
*/
- private static function assert_valid_kind( string $kind ): void {
+ private static function assert_kind( string $kind ): void {
if ( '' === $kind ) {
throw new DomainException( 'Cycle: kind must not be empty.' );
}
}
+ /**
+ * Refuse a cycle without a start or end moment.
+ *
+ * @param string $starts_at_gmt Start (GMT).
+ * @param string $ends_at_gmt End (GMT).
+ * @throws DomainException If either moment is missing.
+ */
+ private static function assert_period( string $starts_at_gmt, string $ends_at_gmt ): void {
+ if ( '' === $starts_at_gmt || '' === $ends_at_gmt ) {
+ throw new DomainException( 'Cycle: starts_at_gmt and ends_at_gmt are required.' );
+ }
+ }
+
+ /**
+ * Refuse a cycle without a currency.
+ *
+ * @param string $currency Currency code.
+ * @throws DomainException If the currency is missing.
+ */
+ private static function assert_currency( string $currency ): void {
+ if ( '' === $currency ) {
+ throw new DomainException( 'Cycle: currency is required.' );
+ }
+ }
+
/**
* 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 {
+ private static function assert_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 )
@@ -612,7 +650,7 @@ final class Cycle {
* @param CycleStatus $status Status to check.
* @throws DomainException If the status is not registered.
*/
- private static function assert_registered_status( CycleStatus $status ): void {
+ private static function assert_status( CycleStatus $status ): void {
if ( ! CycleStatus::is_registered( $status->get_value() ) ) {
throw new DomainException(
sprintf( 'Cycle: status "%s" is not registered.', $status->get_value() )
@@ -639,31 +677,20 @@ final class Cycle {
return new CycleStatus( CycleStatus::PENDING );
}
- return new CycleStatus( ScalarCoercion::coerce_string( $status ) );
+ return new CycleStatus( Coercion::coerce_string( $status ) );
}
/**
- * Normalize and validate a chargeable count.
- *
- * Null passes through (a non-counting cycle); a present value must be a
- * positive integer.
+ * Validate a chargeable count: null (a non-counting cycle) or a positive integer.
*
- * @param mixed $count Raw count value (null, or coercible to int).
- * @return int|null
+ * @param int|null $count Count to validate.
* @throws DomainException If a present count is not positive.
*/
- private static function normalize_count( $count ): ?int {
- if ( null === $count ) {
- return null;
- }
-
- $count = ScalarCoercion::coerce_int( $count );
- if ( $count < 1 ) {
+ private static function assert_count( ?int $count ): void {
+ if ( null !== $count && $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/Plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
index a27b58456ef..c71a6926a51 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -13,7 +13,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
use InvalidArgumentException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\DeliveryPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -177,16 +177,16 @@ final class Plan {
return new self(
null,
- ScalarCoercion::coerce_string( $args['name'] ?? null ),
- ScalarCoercion::coerce_nullable_string( $args['description'] ?? null ),
+ Coercion::coerce_string( $args['name'] ?? null ),
+ Coercion::coerce_nullable_string( $args['description'] ?? null ),
$billing_policy,
$delivery_policy,
$pricing_policy,
- ScalarCoercion::coerce_string( $args['category'] ?? null, self::DEFAULT_CATEGORY ),
- ScalarCoercion::coerce_string( $args['status'] ?? null, self::DEFAULT_STATUS ),
- ScalarCoercion::coerce_int( $args['sort_order'] ?? null, 0 ),
- ScalarCoercion::coerce_nullable_string( $args['merchant_code'] ?? null ),
- ScalarCoercion::coerce_nullable_string( $args['extension_slug'] ?? null )
+ Coercion::coerce_string( $args['category'] ?? null, self::DEFAULT_CATEGORY ),
+ Coercion::coerce_string( $args['status'] ?? null, self::DEFAULT_STATUS ),
+ Coercion::coerce_int( $args['sort_order'] ?? null, 0 ),
+ Coercion::coerce_nullable_string( $args['merchant_code'] ?? null ),
+ Coercion::coerce_nullable_string( $args['extension_slug'] ?? null )
);
}
@@ -203,17 +203,17 @@ final class Plan {
$pricing_policy = self::assert_object_or_null( $row['pricing_policy'] ?? null );
return new self(
- isset( $row['id'] ) ? ScalarCoercion::coerce_int( $row['id'] ) : null,
- ScalarCoercion::coerce_string( $row['name'] ?? null ),
- ScalarCoercion::coerce_nullable_string( $row['description'] ?? null ),
+ isset( $row['id'] ) ? Coercion::coerce_int( $row['id'] ) : null,
+ Coercion::coerce_string( $row['name'] ?? null ),
+ Coercion::coerce_nullable_string( $row['description'] ?? null ),
BillingPolicy::from_array( is_array( $row['billing_policy'] ?? null ) ? $row['billing_policy'] : array() ),
isset( $row['delivery_policy'] ) && is_array( $row['delivery_policy'] ) ? DeliveryPolicy::from_array( $row['delivery_policy'] ) : null,
$pricing_policy,
- ScalarCoercion::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
- ScalarCoercion::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
- ScalarCoercion::coerce_int( $row['sort_order'] ?? null, 0 ),
- ScalarCoercion::coerce_nullable_string( $row['merchant_code'] ?? null ),
- ScalarCoercion::coerce_nullable_string( $row['extension_slug'] ?? null )
+ Coercion::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
+ Coercion::coerce_string( $row['status'] ?? null, self::DEFAULT_STATUS ),
+ Coercion::coerce_int( $row['sort_order'] ?? null, 0 ),
+ Coercion::coerce_nullable_string( $row['merchant_code'] ?? null ),
+ Coercion::coerce_nullable_string( $row['extension_slug'] ?? null )
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
similarity index 60%
rename from packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
rename to packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
index 107ca7a8b1f..574d60974a4 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/Coercion.php
@@ -1,9 +1,8 @@
<?php
/**
- * 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.
+ * Coercion - shared helpers coercing untyped (mixed) values from storage rows or
+ * argument maps into declared scalar and array shapes. Each guards before casting
+ * and falls back when the value is not coercible. WordPress-free Core zone.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
*/
@@ -15,11 +14,11 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
defined( 'ABSPATH' ) || exit;
/**
- * Scalar coercion helpers for hydration boundaries.
+ * Scalar and array coercion helpers for hydration and argument boundaries.
*
* @internal Engine implementation detail. Not part of the supported extension API.
*/
-final class ScalarCoercion {
+final class Coercion {
/**
* Static helper only.
@@ -99,4 +98,67 @@ final class ScalarCoercion {
public static function coerce_float( $value, float $fallback = 0.0 ): float {
return is_numeric( $value ) ? (float) $value : $fallback;
}
+
+ /**
+ * Re-key an array as string-keyed, recovering `array<string, mixed>` from an
+ * `int|string`-keyed array.
+ *
+ * @param array<int|string, mixed> $value Array to re-key.
+ * @return array<string, mixed>
+ * @internal Engine implementation detail. Not part of the supported extension API.
+ */
+ public static function coerce_string_keyed( array $value ): array {
+ $result = array();
+ foreach ( $value as $key => $entry ) {
+ $result[ (string) $key ] = $entry;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Coerce a value to a list of string-keyed rows. A non-array yields an empty
+ * list; non-array rows are skipped.
+ *
+ * @param mixed $value The raw value.
+ * @return array<int, array<string, mixed>>
+ * @internal Engine implementation detail. Not part of the supported extension API.
+ */
+ public static function coerce_list_of_arrays( $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;
+ }
+
+ /**
+ * Coerce a value to a string-keyed map of string-keyed entries. A non-array
+ * yields an empty map; non-array entries are skipped.
+ *
+ * @param mixed $value The raw value.
+ * @return array<string, array<string, mixed>>
+ * @internal Engine implementation detail. Not part of the supported extension API.
+ */
+ public static function coerce_map_of_arrays( $value ): array {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+
+ $map = array();
+ foreach ( $value as $key => $entry ) {
+ if ( is_array( $entry ) ) {
+ $map[ (string) $key ] = self::coerce_string_keyed( $entry );
+ }
+ }
+
+ return $map;
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
index 234d7612ce9..d9c0a890a57 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/MoneyScale.php
@@ -42,6 +42,6 @@ final class MoneyScale {
* @internal Engine implementation detail. Not part of the supported extension API.
*/
public static function normalize_money( $value ): string {
- return number_format( ScalarCoercion::coerce_float( $value ?? '0' ), 8, '.', '' );
+ return number_format( Coercion::coerce_float( $value ?? '0' ), 8, '.', '' );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index 130bbd226e5..fe7df76f64c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -24,7 +24,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
use DateTimeImmutable;
use DateTimeZone;
use DomainException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -124,8 +124,8 @@ final class BillingPolicy {
return new self(
(string) $data['period'],
(int) $data['interval'],
- ScalarCoercion::coerce_nullable_int( $data['min_cycles'] ?? null ),
- ScalarCoercion::coerce_nullable_int( $data['max_cycles'] ?? null ),
+ Coercion::coerce_nullable_int( $data['min_cycles'] ?? null ),
+ Coercion::coerce_nullable_int( $data['max_cycles'] ?? null ),
$trial
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/OrderRef.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/OrderRef.php
deleted file mode 100644
index ebd96918c47..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/OrderRef.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-/**
- * OrderRef - an immutable reference to a WooCommerce order by id.
- *
- * The Core zone never loads a live order object; it holds a reference and
- * commands effects through the Orders host binding in the integration layer.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * OrderRef value object.
- *
- * Immutable identity wrapper.
- */
-final class OrderRef {
-
- /**
- * Order id.
- *
- * @var int
- */
- private $id;
-
- /**
- * Build an order reference.
- *
- * @param int $id Order id.
- * @throws \InvalidArgumentException If the order id is not greater than 0.
- */
- public function __construct( int $id ) {
- if ( $id <= 0 ) {
- throw new \InvalidArgumentException( 'Order id must be greater than 0.' );
- }
- $this->id = $id;
- }
-
- /**
- * The referenced order id.
- */
- public function get_id(): int {
- return $this->id;
- }
-
- /**
- * Value equality by id.
- *
- * @param OrderRef $other Reference to compare against.
- */
- public function equals( OrderRef $other ): bool {
- return $this->id === $other->id;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index 011d72467b7..020b88710ef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -17,7 +17,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
use DomainException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -97,7 +97,7 @@ final class PlanSnapshot {
* 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'] ) ? ScalarCoercion::coerce_int( $this->data['selling_plan_id'] ) : null;
+ return isset( $this->data['selling_plan_id'] ) ? Coercion::coerce_int( $this->data['selling_plan_id'] ) : null;
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
deleted file mode 100644
index d819f4ed291..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ /dev/null
@@ -1,297 +0,0 @@
-<?php
-/**
- * Builds and persists a {@see Contract} (plus its origin {@see Cycle}) from a paid
- * checkout order, and links order <-> contract in both directions. Renewals need no
- * arming beyond this: the contract's `next_payment_gmt` places it on the due index the
- * batch dispatcher scans.
- *
- * Reads a live `WC_Order`; the order never crosses into Core - only the snapshot
- * values pulled off it do.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use Throwable;
-use WC_Order;
-use WC_Order_Item_Product;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\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;
-
-/**
- * Order -> contract factory.
- */
-final class ContractFactory {
-
- /**
- * The repository the factory persists through.
- *
- * @var ContractRepository
- */
- private $contracts;
-
- /**
- * Build a factory that persists through the given repository.
- *
- * @param ContractRepository|null $contracts Repository to persist through; a
- * default instance is created when
- * omitted.
- */
- public function __construct( ?ContractRepository $contracts = null ) {
- $this->contracts = $contracts ?? new ContractRepository();
- }
-
- /**
- * Build, persist, and link a contract for `$order` on `$plan`.
- *
- * 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: 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 a write fails.
- */
- public function create_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): Contract {
- $plan_id = $plan->get_id();
- if ( null === $plan_id ) {
- throw new \RuntimeException( 'ContractFactory::create_from_order(): the selling plan must be persisted (have an id) before a contract can reference it.' );
- }
-
- // An unsaved order reports id 0, 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.' );
- }
-
- $paid_date = $order->get_date_paid();
- $anchor = null !== $paid_date
- ? new DateTimeImmutable( '@' . $paid_date->getTimestamp() )
- : 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'] )
- ? ScalarCoercion::coerce_string( $overrides['next_payment_gmt'] )
- : $plan->get_billing_policy()->compute_first_renewal_from( $anchor )->format( 'Y-m-d H:i:s' );
-
- $expected_total = isset( $overrides['billing_total'] ) ? ScalarCoercion::coerce_string( $overrides['billing_total'] ) : (string) $order->get_total();
- $currency = $order->get_currency();
-
- $contract_defaults = array(
- 'customer_id' => $order->get_customer_id(),
- '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 ),
- '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(),
- 'items' => $this->map_items( $order ),
- 'addresses' => $this->map_addresses( $order ),
- 'meta' => array(),
- );
-
- $contract = Contract::create( array_merge( $contract_defaults, $overrides ) );
-
- $origin_cycle = $this->build_origin_cycle( $order, $plan, $period_start, $next_payment, $expected_total, $currency );
-
- $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' => new CycleStatus( 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.
- *
- * `pricing_policy` freezes the plan's pricing payload as is. It is an explicit
- * `null` when the plan has none, so a reader can tell "no pricing at signup"
- * from a snapshot written before the key existed.
- *
- * @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(),
- 'pricing_policy' => $plan->get_pricing_policy(),
- )
- );
- }
-
- /**
- * 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 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.
- */
- private function tag_origin_order( WC_Order $order, int $contract_id ): void {
- try {
- $order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
- $order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_PARENT );
- $order->save();
- } catch ( Throwable $e ) {
- wc_get_logger()->warning(
- sprintf(
- 'ContractFactory: failed to tag origin order %d for contract %d: %s. The contract is persisted; the order-side link can be rebuilt from the contract row.',
- $order->get_id(),
- $contract_id,
- $e->getMessage()
- ),
- array(
- 'source' => 'woocommerce-subscriptions-engine',
- 'contract_id' => $contract_id,
- 'order_id' => $order->get_id(),
- )
- );
- }
- }
-
- /**
- * Map the order's line items to the contract item-row shape.
- *
- * Only `line_item` rows are carried (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>>
- */
- private function map_items( WC_Order $order ): array {
- $items = array();
-
- foreach ( $order->get_items() as $item ) {
- if ( ! $item instanceof WC_Order_Item_Product ) {
- continue;
- }
-
- $items[] = array(
- 'item_name' => $item->get_name(),
- 'item_type' => 'line_item',
- 'product_id' => $item->get_product_id(),
- 'variation_id' => $item->get_variation_id(),
- 'quantity' => (string) $item->get_quantity(),
- 'subtotal' => (string) $item->get_subtotal(),
- 'total' => (string) $item->get_total(),
- 'taxes' => $item->get_taxes(),
- );
- }
-
- return $items;
- }
-
- /**
- * Map the order's billing and shipping addresses to the contract shape.
- *
- * @param WC_Order $order The order to read addresses from.
- * @return array<string, array<string, mixed>>
- */
- private function map_addresses( WC_Order $order ): array {
- return array(
- Contract::ADDRESS_BILLING => $order->get_address( 'billing' ),
- Contract::ADDRESS_SHIPPING => $order->get_address( 'shipping' ),
- );
- }
-
- /**
- * Best-effort extraction of the payment-token id from `$order`.
- *
- * Reads WooCommerce's per-order payment tokens (populated when a gateway calls
- * `$order->add_payment_token()`); 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.
- */
- private function extract_payment_token_id( WC_Order $order ): ?int {
- $tokens = $order->get_payment_tokens();
- if ( ! empty( $tokens ) ) {
- $id = (int) end( $tokens );
- if ( $id > 0 ) {
- return $id;
- }
- }
-
- return null;
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
index 9fa91cee1cc..94c975e157d 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/OrderLinkage.php
@@ -1,9 +1,9 @@
<?php
/**
- * 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.
+ * Order-side meta keys linking renewal-side orders (renewals, switches, resubscribes)
+ * to contracts, making the relationship queryable from the order side. The origin
+ * order is linked by the contract's `origin_order_id` instead. The engine owns these
+ * keys; consumers read them through this class rather than hard-coding the strings.
*
* Written to WooCommerce order meta, which works under both HPOS and the legacy
* CPT order store.
@@ -15,8 +15,6 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
-use InvalidArgumentException;
-
defined( 'ABSPATH' ) || exit;
/**
@@ -36,11 +34,6 @@ final class OrderLinkage {
*/
public const META_RELATION_TYPE = '_subscription_relation_type';
- /**
- * The order whose checkout created the contract (the contract's `origin_order_id`).
- */
- public const RELATION_PARENT = 'parent';
-
/**
* A renewal order - created by the renewal engine when a cycle bills.
*/
@@ -55,37 +48,4 @@ final class OrderLinkage {
* A resubscribe order - customer restarted a previously-cancelled contract.
*/
public const RELATION_RESUBSCRIBE = 'resubscribe';
-
- /**
- * All recognized relation types.
- *
- * @return array<int, string>
- */
- public static function relation_types(): array {
- return array(
- self::RELATION_PARENT,
- self::RELATION_RENEWAL,
- self::RELATION_SWITCH,
- self::RELATION_RESUBSCRIBE,
- );
- }
-
- /**
- * Throw if `$relation` is not one of the known relation types, 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.
- */
- public static function assert_relation( string $relation ): void {
- if ( ! in_array( $relation, self::relation_types(), true ) ) {
- throw new InvalidArgumentException(
- sprintf(
- 'Unknown contract-order relation type: "%s". Expected one of: %s.',
- esc_html( $relation ),
- esc_html( implode( ', ', self::relation_types() ) )
- )
- );
- }
- }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
index 1f5c4c2bf0e..21f8daaa374 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/RelatedOrders.php
@@ -1,9 +1,9 @@
<?php
/**
- * RelatedOrders - reads the orders linked to a contract from the order side, via the
- * {@see OrderLinkage} meta the engine tags onto every contract-related order (the origin
- * order at checkout, plus renewals / switches / resubscribes). Returns live WC_Order
- * objects newest first; shaping them for presentation is the caller's job.
+ * RelatedOrders - reads the orders related to a contract: the origin order by the
+ * contract's `origin_order_id`, plus the orders tagged with the {@see OrderLinkage} meta
+ * (renewals / switches / resubscribes). Returns live WC_Order objects newest first;
+ * shaping them for presentation is the caller's job.
*
* Integration zone: WordPress-native. The flat `meta_key`/`meta_value` lookup round-trips
* through both the HPOS and legacy order stores.
@@ -16,63 +16,81 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout;
use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
defined( 'ABSPATH' ) || exit;
/**
- * Order-side read of a contract's related orders.
+ * Read of a contract's related orders.
*/
final class RelatedOrders {
/**
- * The orders linked to `$contract_id`, newest first.
+ * The orders related to `$contract_id`, newest first.
*
- * Reads the orders tagged with this contract through the order-side
- * {@see OrderLinkage::META_CONTRACT_ID} meta; the contract row carries the reverse
- * `origin_order_id`. Returns an empty array when none are linked.
+ * The origin order is read by the contract's `origin_order_id`; the others through the
+ * order-side {@see OrderLinkage::META_CONTRACT_ID} meta. An order found both ways appears
+ * once. Returns an empty array for an unknown contract or when none are related.
*
* The window args exist because a long-running contract accumulates one renewal
- * order per period - unbounded reads grow with contract age, so paging consumers
- * pass a window. The default stays "all", newest first.
+ * order per period, so paging consumers pass a window. The default stays "all".
*
* @param int $contract_id Contract id.
* @param int $limit Maximum orders to return; any negative (default -1) for all, 0 for none.
* @param int $offset Orders to skip (for paging). Default 0.
- * @return array<int, WC_Order> Linked orders, newest first.
+ * @return array<int, WC_Order> Related orders, newest first.
*/
public function for_contract( int $contract_id, int $limit = -1, int $offset = 0 ): array {
if ( 0 === $limit ) {
return array();
}
- // Any other non-positive limit means "all": -1 is the wc_get_orders sentinel,
- // and an unguarded 0 would fall back to the site's posts-per-page default.
- $limit = $limit < 0 ? -1 : $limit;
+ $contract = ( new ContractRepository() )->find_summary( $contract_id );
+ if ( null === $contract ) {
+ return array();
+ }
- $orders = wc_get_orders(
+ $by_id = array();
+
+ $origin_id = $contract->get_origin_order_id();
+ $origin = null === $origin_id ? false : wc_get_order( $origin_id );
+ if ( $origin instanceof WC_Order ) {
+ $by_id[ $origin->get_id() ] = $origin;
+ }
+
+ // The origin adds at most one entry, so the newest `$offset + $limit` linked orders
+ // always cover the requested page. Order like the merge below: newest first, then id.
+ $linked = wc_get_orders(
array(
- 'limit' => $limit,
- 'offset' => max( 0, $offset ),
+ 'limit' => $limit < 0 ? -1 : max( 0, $offset ) + $limit,
+ 'orderby' => array(
+ 'date' => 'DESC',
+ 'ID' => 'DESC',
+ ),
'status' => 'any',
'type' => 'shop_order',
- 'orderby' => 'date',
- 'order' => 'DESC',
'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
)
);
-
- if ( ! is_array( $orders ) ) {
- return array();
- }
-
- $result = array();
- foreach ( $orders as $order ) {
+ foreach ( is_array( $linked ) ? $linked : array() as $order ) {
if ( $order instanceof WC_Order ) {
- $result[] = $order;
+ $by_id[ $order->get_id() ] = $order;
}
}
- return $result;
+ $orders = array_values( $by_id );
+ usort(
+ $orders,
+ static function ( WC_Order $a, WC_Order $b ): int {
+ $a_created = $a->get_date_created();
+ $b_created = $b->get_date_created();
+ $by_date = ( null === $b_created ? 0 : $b_created->getTimestamp() ) <=> ( null === $a_created ? 0 : $a_created->getTimestamp() );
+
+ return 0 !== $by_date ? $by_date : $b->get_id() <=> $a->get_id();
+ }
+ );
+
+ return array_slice( $orders, max( 0, $offset ), $limit < 0 ? null : $limit );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
index 6e01b577de1..ad7b0a3723e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Cancellation.php
@@ -66,7 +66,8 @@ final class Cancellation {
* Cancel `$contract`: move it to cancelled, disarm its next-due moment, and close any
* mid-charge cycle.
*
- * Only an active, on-hold or pending-cancellation contract can be cancelled; cancelling an
+ * Only a draft, active, on-hold or pending-cancellation contract can be cancelled (a draft is
+ * never armed, so there is no due moment to disarm); cancelling an
* already cancelled contract is an idempotent no-op that still succeeds and fires the
* action. Any other status - including one that is not registered - raises a
* `DomainException`. The next-payment date and any hold anchor are cleared so the due scan
@@ -86,15 +87,14 @@ final class Cancellation {
}
$previous = $contract->get_status();
- $cancelable = array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED );
+ $cancelable = array( ContractStatus::DRAFT, ContractStatus::ACTIVE, ContractStatus::ON_HOLD, ContractStatus::PENDING_CANCELLATION, ContractStatus::CANCELLED );
if ( ! in_array( $previous, $cancelable, true ) ) {
- throw new \DomainException( 'Cancellation::cancel(): only an active, on-hold or pending-cancellation contract can be cancelled.' );
+ throw new \DomainException( 'Cancellation::cancel(): only a draft, active, on-hold or pending-cancellation contract can be cancelled.' );
}
if ( ContractStatus::CANCELLED !== $previous ) {
$contract->set_status( ContractStatus::CANCELLED );
$contract->set_next_payment_gmt( null );
- $contract->set_meta( Hold::ANCHOR_META_KEY, null );
}
// Compare-and-set on the status read above: a concurrent transition (another
@@ -104,6 +104,10 @@ final class Cancellation {
throw new \DomainException( 'Cancellation::cancel(): the contract state changed concurrently; nothing was written.' );
}
+ if ( ContractStatus::CANCELLED !== $previous ) {
+ Hold::clear_anchor( $this->contracts, $id );
+ }
+
// Close a charge caught mid-flight: a still-pending head cycle is cancelled so no stale
// claim is left open. A settled (billed/failed/cancelled) cycle is left as is.
$current = $this->contracts->find_chain_head( $id );
@@ -113,7 +117,7 @@ final class Cancellation {
}
/**
- * Fires after a contract is cancelled.
+ * Fires after a contract is cancelled. Fires immediately after the write, not after a surrounding transaction commits.
*
* @param Contract $contract The cancelled contract.
*/
@@ -163,13 +167,12 @@ final class Cancellation {
// up to (not through) it. A held contract's moment lives in the hold anchor.
$period_end = $contract->get_next_payment_gmt();
if ( null === $period_end && ContractStatus::ON_HOLD === $previous ) {
- $period_end = Hold::read_anchor( $contract );
+ $period_end = Hold::read_anchor( $this->contracts, $id );
}
if ( null === $contract->get_end_gmt() && null !== $period_end ) {
$contract->set_end_gmt( $period_end );
}
- $contract->set_meta( Hold::ANCHOR_META_KEY, null );
$contract->set_next_payment_gmt( null );
}
@@ -179,8 +182,13 @@ final class Cancellation {
throw new \DomainException( 'Cancellation::cancel_at_period_end(): the contract state changed concurrently; nothing was written.' );
}
+ if ( ContractStatus::PENDING_CANCELLATION !== $previous ) {
+ Hold::clear_anchor( $this->contracts, $id );
+ }
+
/**
* Fires after a contract is set to wind down at the end of the current period.
+ * Fires immediately after the write, not after a surrounding transaction commits.
*
* @param Contract $contract The pending-cancellation contract.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
index 6a9533929c2..786b61dbd98 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Hold.php
@@ -106,7 +106,7 @@ final class Hold {
}
/**
- * Fires after a contract is put on hold.
+ * Fires after a contract is put on hold. Fires immediately after the write, not after a surrounding transaction commits.
*
* @param Contract $contract The held contract.
*/
@@ -116,60 +116,90 @@ final class Hold {
}
/**
- * Store the next-due moment as the hold anchor while the contract is still active,
- * before the hold disarms it.
+ * Store the next-due moment as the hold anchor before the hold disarms it.
*
- * The repository writes the row and its meta as separate statements (no
- * transaction), so the anchor is written and read back first: if it did not
- * persist, nothing has been disarmed yet and the hold aborts. A contract with no
- * next-due moment stores no anchor (null removes the key). An anchor left behind by
- * a hold that then loses its compare-and-set is harmless: the next hold overwrites
- * it and cancellation clears it.
+ * The anchor lives in contract meta, written apart from the row (no transaction),
+ * so it is written and read back first: if it did not persist, nothing has been
+ * disarmed yet and the hold aborts. A contract with no next-due moment stores no
+ * anchor. An anchor left behind by a hold that then loses its compare-and-set is
+ * harmless: the next hold overwrites it and cancellation clears it.
*
* @param Contract $contract Active contract about to be held. Must have an id.
- * @throws \DomainException If the contract stopped being active concurrently.
* @throws RuntimeException If the anchor could not be stored.
*/
private function persist_anchor( Contract $contract ): void {
+ $id = (int) $contract->get_id();
$next_payment_gmt = $contract->get_next_payment_gmt();
- $contract->set_meta( self::ANCHOR_META_KEY, $next_payment_gmt );
- if ( ! $this->contracts->update_if_status( $contract, ContractStatus::ACTIVE ) ) {
- throw new \DomainException( 'Hold::hold(): the contract state changed concurrently; nothing was written.' );
+ try {
+ if ( null === $next_payment_gmt ) {
+ $this->contracts->delete_meta( $id, self::ANCHOR_META_KEY );
+ } else {
+ $this->contracts->update_meta( $id, self::ANCHOR_META_KEY, $next_payment_gmt );
+ }
+ } catch ( RuntimeException $e ) {
+ throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the previous exception is not output.
}
- $stored = $this->contracts->find( (int) $contract->get_id() );
- $anchor = null === $stored ? null : ( $stored->get_meta()[ self::ANCHOR_META_KEY ] ?? null );
+ $stored = $this->contracts->get_meta( $id, self::ANCHOR_META_KEY, true );
+ $anchor = '' === $stored ? null : $stored;
if ( $anchor !== $next_payment_gmt ) {
throw new RuntimeException( 'Hold::hold(): the hold anchor could not be stored; the contract was not held.' );
}
}
/**
- * The hold anchor stored on `$contract`, or null when there is none.
+ * Clear a contract's hold anchor after a status write has committed.
+ *
+ * Best effort: a failed delete is logged and swallowed, so the caller still finishes
+ * the transition it already wrote (cycle close, lifecycle action). A leftover anchor
+ * is harmless: the next hold overwrites it and only an on-hold contract reads it.
+ *
+ * @param ContractRepository $contracts Contract repository.
+ * @param int $contract_id Contract id.
+ */
+ public static function clear_anchor( ContractRepository $contracts, int $contract_id ): void {
+ try {
+ $contracts->delete_meta( $contract_id, self::ANCHOR_META_KEY );
+ } catch ( RuntimeException $e ) {
+ wc_get_logger()->warning(
+ sprintf( 'Hold: the hold anchor of contract %d could not be cleared: %s', $contract_id, $e->getMessage() ),
+ array(
+ 'source' => 'woocommerce-subscriptions-engine',
+ 'contract_id' => $contract_id,
+ )
+ );
+ }
+ }
+
+ /**
+ * The hold anchor stored for a contract, or null when there is none.
*
* The one reader of {@see self::ANCHOR_META_KEY}: a value that is not a well-formed
* GMT datetime (`Y-m-d H:i:s`) counts as absent and is logged, since a flow resuming
* or ending from it would otherwise act on garbage.
*
- * @param Contract $contract Contract to read.
+ * @param ContractRepository $contracts Contract repository.
+ * @param int $contract_id Contract id.
*/
- public static function read_anchor( Contract $contract ): ?string {
- $anchor = $contract->get_meta()[ self::ANCHOR_META_KEY ] ?? '';
- if ( '' === $anchor ) {
+ public static function read_anchor( ContractRepository $contracts, int $contract_id ): ?string {
+ $anchor = $contracts->get_meta( $contract_id, self::ANCHOR_META_KEY, true );
+ if ( '' === $anchor || null === $anchor ) {
return null;
}
- $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
- if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
- return $anchor;
+ if ( is_string( $anchor ) ) {
+ $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $anchor, new DateTimeZone( 'UTC' ) );
+ if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $anchor ) {
+ return $anchor;
+ }
}
wc_get_logger()->warning(
- sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', (int) $contract->get_id() ),
+ sprintf( 'Hold: contract %d has a malformed hold anchor; it is ignored.', $contract_id ),
array(
'source' => 'woocommerce-subscriptions-engine',
- 'contract_id' => $contract->get_id(),
+ 'contract_id' => $contract_id,
)
);
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
index e7629263066..5f9bba82130 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Contracts/Reactivation.php
@@ -122,10 +122,9 @@ final class Reactivation {
// A next-due moment set while held was re-armed deliberately (hold clears it), so
// it wins; otherwise resume from the hold anchor, ignoring a malformed one.
- $anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $contract );
+ $anchor = $contract->get_next_payment_gmt() ?? Hold::read_anchor( $this->contracts, $id );
$contract->set_next_payment_gmt( $this->recompute_next_payment( $contract, $anchor, $now, $this->billing_policy( $contract ) ) );
- $contract->set_meta( Hold::ANCHOR_META_KEY, null );
$contract->set_status( ContractStatus::ACTIVE );
// Compare-and-set on the ON_HOLD status read above: a concurrent transition
@@ -135,9 +134,11 @@ final class Reactivation {
throw new DomainException( 'Reactivation::reactivate(): the contract state changed concurrently; nothing was written.' );
}
+ Hold::clear_anchor( $this->contracts, $id );
+
/**
* Fires after a held contract is reactivated: its renewal is re-armed, or left
- * unscheduled when there was no next-due moment to resume from.
+ * unscheduled when there was no next-due moment to resume from. Fires immediately after the write, not after a surrounding transaction commits.
*
* @param Contract $contract The reactivated contract.
*/
@@ -236,7 +237,12 @@ final class Reactivation {
}
}
- $plan = $this->plans->find( $contract->get_selling_plan_id() );
+ $plan_id = $contract->get_selling_plan_id();
+ if ( null === $plan_id ) {
+ return null;
+ }
+
+ $plan = $this->plans->find( $plan_id );
return $plan instanceof Plan ? $plan->get_billing_policy() : null;
}
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 fe021b7e95d..fc5960553f5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -42,7 +42,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
@@ -217,11 +217,11 @@ final class RenewalEngine {
* most once even under overlapping runs. Order reconciliation follows the claim, so the
* cycle chain - not the mutable order - is the idempotency authority.
*
- * Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no chain, an
- * unresolvable plan, a non-adjacent count, a gateway that cannot charge renewals) so the
- * scheduled caller can park and a manual caller can return null; returns null for an
- * idempotent no-op (a non-active contract, a live claim, an already-settled cycle, an
- * unbuildable order).
+ * Throws {@see RenewalNotProcessable} for a pre-flight impossibility (no currency, no
+ * chain, an unresolvable plan, a non-adjacent count, a gateway that cannot charge
+ * renewals) so the scheduled caller can park and a manual caller can return null; returns
+ * null for an idempotent no-op (a non-active contract, a live claim, an already-settled
+ * cycle, an unbuildable order). A contract without a customer renews as a guest order.
*
* @param RenewalIntent $intent The contract and cycle count to bill.
* @param DateTimeImmutable $now The processing moment (the lease clock for a claim).
@@ -271,6 +271,13 @@ final class RenewalEngine {
return null;
}
+ // A renewal input an extension may not have supplied yet: without it no order can
+ // be built, so the scheduled caller parks the contract out of the due set. A null
+ // customer is not one: the renewal order is built as a guest order.
+ if ( null === $contract->get_currency() ) {
+ throw new RenewalNotProcessable( 'the contract has no currency' );
+ }
+
// Pre-flight capability gate, ahead of the claim so an unchargeable renewal never
// claims a cycle or creates an order. Without it the charge hook would fire into
// nothing and the cycle would park `processing` - a stall that misreads as an
@@ -358,6 +365,13 @@ final class RenewalEngine {
// reclaimed stall resuming an earlier attempt - already announced its creation, so
// re-firing would double one-time side effects (customer emails, analytics).
if ( $order_created ) {
+ /**
+ * Fires after a renewal order is created, before it is charged.
+ * Fires immediately after the write, not after a surrounding transaction commits.
+ *
+ * @param WC_Order $renewal_order The new renewal order.
+ * @param Contract $contract The contract being renewed.
+ */
do_action( self::RENEWAL_ORDER_CREATED_ACTION, $renewal_order, $contract );
}
$this->attempt_charge( $renewal_order, $contract );
@@ -402,7 +416,12 @@ final class RenewalEngine {
}
}
- $plan = $this->plans->find( $contract->get_selling_plan_id() );
+ $plan_id = $contract->get_selling_plan_id();
+ if ( null === $plan_id ) {
+ return null;
+ }
+
+ $plan = $this->plans->find( $plan_id );
return $plan instanceof Plan ? $plan->get_billing_policy() : null;
}
@@ -450,7 +469,7 @@ final class RenewalEngine {
'count' => $cycle_count,
'period_start' => $head->get_ends_at_gmt(),
'expected_total' => $contract->get_billing_total(),
- 'currency' => $contract->get_currency(),
+ 'currency' => (string) $contract->get_currency(),
'extension_slug' => $contract->get_extension_slug(),
'plan_snapshot_id' => $contract->get_plan_snapshot_id(),
'items_snapshot_id' => $contract->get_items_snapshot_id(),
@@ -638,7 +657,7 @@ final class RenewalEngine {
return;
}
- $contract_id = ScalarCoercion::coerce_int( $order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
+ $contract_id = Coercion::coerce_int( $order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
if ( $contract_id <= 0 ) {
return;
}
@@ -758,6 +777,7 @@ final class RenewalEngine {
/**
* Fires after a renewal cycle is billed and the contract schedule advanced.
+ * Fires immediately after the write, not after a surrounding transaction commits.
*
* @param Contract $contract The renewed contract.
* @param Cycle $cycle The newly-billed cycle.
@@ -823,7 +843,7 @@ final class RenewalEngine {
$renewal_order = wc_create_order(
array(
- 'customer_id' => $contract->get_customer_id(),
+ 'customer_id' => (int) $contract->get_customer_id(),
'status' => OrderStatus::CHECKOUT_DRAFT,
'created_via' => 'woocommerce_subscriptions_engine_renewal',
)
@@ -842,7 +862,7 @@ final class RenewalEngine {
$instrument = $contract->get_payment_instrument();
- $renewal_order->set_currency( $contract->get_currency() );
+ $renewal_order->set_currency( (string) $contract->get_currency() );
if ( null !== $instrument->get_gateway() ) {
$renewal_order->set_payment_method( (string) $instrument->get_gateway() );
}
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 4240f5d5fea..a4358b88b20 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -1,15 +1,18 @@
<?php
/**
- * Persistence for the live {@see Contract} (row + items / addresses / meta) plus
- * targeted cycle access. Owns the $wpdb access across the contract-side tables.
+ * Persistence for the live {@see Contract} (row + items / addresses) plus contract
+ * meta and targeted cycle access. Owns the $wpdb access across the contract-side tables.
*
* 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_chain_head()}, {@see self::max_count()},
+ * through purpose-built reads ({@see self::find_chain_head()},
* 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}.
+ * Meta is read and written only through the meta methods ({@see self::add_meta()} etc.),
+ * so a whole-contract write never rewrites meta. It opens no transactions and keeps no
+ * object cache; a caller may wrap several calls in its own transaction.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
*/
@@ -24,7 +27,7 @@ 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\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -35,25 +38,6 @@ defined( 'ABSPATH' ) || exit;
*/
final class ContractRepository {
- /**
- * Address columns persisted to the addresses table.
- *
- * @var array<int, string>
- */
- private const ADDRESS_COLUMNS = array(
- 'first_name',
- 'last_name',
- 'company',
- 'address_1',
- 'address_2',
- 'city',
- 'state',
- 'postcode',
- 'country',
- 'email',
- 'phone',
- );
-
/**
* Logger source tag.
*/
@@ -77,7 +61,7 @@ final class ContractRepository {
}
/**
- * Insert a new contract and its items, addresses, and meta.
+ * Insert a new contract and its items and addresses.
*
* 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
@@ -86,7 +70,7 @@ final class ContractRepository {
*
* @param Contract $contract Contract to insert.
* @return int The new contract id.
- * @throws \RuntimeException If the contract insert fails.
+ * @throws \RuntimeException If the contract row or a child row insert fails.
*/
public function insert( Contract $contract ): int {
global $wpdb;
@@ -115,56 +99,21 @@ final class ContractRepository {
$this->insert_items( $id, $contract->get_items() );
$this->insert_addresses( $id, $contract->get_addresses() );
- $this->insert_meta( $id, $contract->get_meta() );
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 reconciles items / addresses / meta only
- * when they differ - so the common renewal-cache write (status, next_payment_gmt)
+ * Updates the contract row in place, then reconciles items / addresses only when
+ * they differ (meta is never touched) - 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.
+ * @throws \RuntimeException If the contract has no id, its row no longer exists, or a write fails.
*/
public function update( Contract $contract ): bool {
$id = $contract->get_id();
@@ -180,6 +129,65 @@ final class ContractRepository {
return true;
}
+ /**
+ * Write only the named contract columns (plus `date_updated_gmt`) from the entity,
+ * and replace items / addresses when named. Unnamed columns keep their stored
+ * values, so a concurrent write to them is not reverted. Existence is checked only
+ * when the row write changes nothing, before any child write.
+ *
+ * @param Contract $contract Contract carrying the values. Must have an id.
+ * @param array<int, string> $fields Contract column names, plus `items` / `addresses`.
+ * @return bool False when the contract row no longer exists (nothing is written).
+ * @throws \InvalidArgumentException If a field is not a writable contract column.
+ * @throws \RuntimeException If the contract has no id or the write fails.
+ */
+ public function update_fields( Contract $contract, array $fields ): 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.' );
+ }
+
+ $storage = $contract->to_storage();
+ $columns = array();
+ foreach ( $fields as $field ) {
+ if ( 'items' === $field || 'addresses' === $field ) {
+ continue;
+ }
+ if ( 'extension_slug' === $field || ! array_key_exists( $field, $storage ) ) {
+ throw new \InvalidArgumentException( esc_html( sprintf( 'Cannot update contract field "%s".', $field ) ) );
+ }
+ $columns[ $field ] = $storage[ $field ];
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $updated = $wpdb->update(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ),
+ array_merge( $columns, array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ) ) ),
+ array( 'id' => (int) $id )
+ );
+
+ if ( false === $updated ) {
+ throw new \RuntimeException( 'Failed to update contract.' );
+ }
+
+ // Zero changed rows: a missing row, or identical values written within the same second.
+ if ( 0 === $updated && ! $this->exists( $id ) ) {
+ return false;
+ }
+
+ if ( in_array( 'items', $fields, true ) ) {
+ $this->replace_items( $id, $contract->get_items() );
+ }
+
+ if ( in_array( 'addresses', $fields, true ) ) {
+ $this->replace_addresses( $id, $contract->get_addresses() );
+ }
+
+ return true;
+ }
+
/**
* Update the contract row (and children) ONLY while its stored status still matches
* `$expected_status` - the optimistic compare-and-set for status-sensitive writes,
@@ -244,8 +252,8 @@ final class ContractRepository {
}
/**
- * Fetch a contract by id, hydrating the live entity with its items / addresses /
- * meta, plus its frozen plan terms ({@see Contract::get_plan_snapshot()}) from
+ * Fetch a contract by id, hydrating the live entity with its items / addresses,
+ * plus its frozen plan terms ({@see Contract::get_plan_snapshot()}) from
* `plan_snapshot_id` - so every full read carries the billing cadence off the
* snapshot, with no live {@see PlanRepository} join. Cycles are NOT hydrated - they
* are reached on demand through the targeted cycle reads. For list / guard paths
@@ -287,24 +295,23 @@ final class ContractRepository {
return null;
}
- return $this->hydrate_row( self::as_string_keyed( $row ) );
+ return $this->hydrate_row( Coercion::coerce_string_keyed( $row ) );
}
/**
* Hydrate a fetched contract row into the full live entity: frozen plan terms,
- * items, addresses, and meta - the one full-read construction path.
+ * items and addresses - the one full-read construction path.
*
* @param array<string, mixed> $row Contract row.
*/
private function hydrate_row( array $row ): Contract {
- $id = ScalarCoercion::coerce_int( $row['id'] ?? 0 );
+ $id = Coercion::coerce_int( $row['id'] ?? 0 );
return Contract::from_storage(
$row,
- $this->find_plan_snapshot( ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null ) ),
+ $this->find_plan_snapshot( Coercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null ) ),
$this->find_items( $id ),
- $this->find_addresses( $id ),
- $this->find_meta( $id )
+ $this->find_addresses( $id )
);
}
@@ -525,11 +532,11 @@ final class ContractRepository {
if ( ! is_array( $row ) ) {
continue;
}
- $status = ScalarCoercion::coerce_string( $row['status'] ?? '' );
+ $status = Coercion::coerce_string( $row['status'] ?? '' );
// A row whose stored status is not registered is ignored, not added as a
// stray key - the map stays exactly ContractStatus::get_all().
if ( array_key_exists( $status, $counts ) ) {
- $counts[ $status ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
+ $counts[ $status ] = Coercion::coerce_int( $row['total'] ?? 0 );
}
}
@@ -561,7 +568,7 @@ final class ContractRepository {
$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params ) );
}
- return ScalarCoercion::coerce_int( $total );
+ return Coercion::coerce_int( $total );
}
/**
@@ -607,9 +614,9 @@ final class ContractRepository {
if ( ! is_array( $row ) ) {
continue;
}
- $cid = ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 );
+ $cid = Coercion::coerce_int( $row['contract_id'] ?? 0 );
if ( array_key_exists( $cid, $counts ) ) {
- $counts[ $cid ] = ScalarCoercion::coerce_int( $row['total'] ?? 0 );
+ $counts[ $cid ] = Coercion::coerce_int( $row['total'] ?? 0 );
}
}
@@ -628,13 +635,13 @@ final class ContractRepository {
$clean_rows = array();
foreach ( $rows as $row ) {
if ( is_array( $row ) ) {
- $clean_rows[] = self::as_string_keyed( $row );
+ $clean_rows[] = Coercion::coerce_string_keyed( $row );
}
}
$snapshot_ids = array();
foreach ( $clean_rows as $row ) {
- $snapshot_id = ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
+ $snapshot_id = Coercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
if ( null !== $snapshot_id ) {
$snapshot_ids[ $snapshot_id ] = $snapshot_id;
}
@@ -643,7 +650,7 @@ final class ContractRepository {
$contracts = array();
foreach ( $clean_rows as $row ) {
- $snapshot_id = ScalarCoercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
+ $snapshot_id = Coercion::coerce_nullable_int( $row['plan_snapshot_id'] ?? null );
$contracts[] = Contract::from_storage( $row, null !== $snapshot_id ? ( $snapshots[ $snapshot_id ] ?? null ) : null );
}
@@ -756,10 +763,10 @@ final class ContractRepository {
}
$head_count = $row['head_count'] ?? null;
$result[] = new RenewalCandidate(
- ScalarCoercion::coerce_int( $row['contract_id'] ?? 0 ),
- null === $head_count ? null : ScalarCoercion::coerce_int( $head_count ),
- ScalarCoercion::coerce_string( $row['head_status'] ?? '' ),
- ScalarCoercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
+ Coercion::coerce_int( $row['contract_id'] ?? 0 ),
+ null === $head_count ? null : Coercion::coerce_int( $head_count ),
+ Coercion::coerce_string( $row['head_status'] ?? '' ),
+ Coercion::coerce_string( $row['head_ends_at_gmt'] ?? '' )
);
}
@@ -779,7 +786,7 @@ final class ContractRepository {
* args, so the shape can widen without a signature change. Ordered by id DESC (monotonic
* with creation) so the list is newest-first and stable for paging.
*
- * Each row is row-only (no items / addresses / meta), but its frozen plan terms are
+ * Each row is row-only (no items / addresses), but its frozen plan terms are
* hydrated ({@see Contract::get_plan_snapshot()}) so the list rows carry the billing
* cadence off the snapshot - batch-loaded in ONE `IN()` read for the whole page, not
* one read per row.
@@ -790,17 +797,28 @@ final class ContractRepository {
*
* @type int $limit Maximum contracts to return. Default 20.
* @type int $offset Rows to skip (for paging). Default 0.
- * @type string $status Optional status filter (one of {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus}).
+ * @type string|string[] $status Optional status filter: one status or a list of them
+ * ({@see ContractStatus}). Unregistered values are dropped;
+ * ignored when none remain.
* }
* @return array<int, Contract> Contracts the customer owns, newest first.
*/
public function find_by_customer_id( int $customer_id, ?array $args = null ): array {
global $wpdb;
- $args = $args ?? array();
- $limit = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? (int) $args['limit'] : 20;
- $offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? (int) $args['offset'] : 0;
- $status = isset( $args['status'] ) && is_string( $args['status'] ) && '' !== $args['status'] ? $args['status'] : null;
+ $args = $args ?? array();
+ $limit = isset( $args['limit'] ) && is_numeric( $args['limit'] ) ? (int) $args['limit'] : 20;
+ $offset = isset( $args['offset'] ) && is_numeric( $args['offset'] ) ? (int) $args['offset'] : 0;
+ $statuses = array_values(
+ array_unique(
+ array_filter(
+ (array) ( $args['status'] ?? array() ),
+ static function ( $status ): bool {
+ return is_string( $status ) && ContractStatus::is_registered( $status );
+ }
+ )
+ )
+ );
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
@@ -809,9 +827,9 @@ final class ContractRepository {
// interpolated).
$where = 'customer_id = %d';
$params = array( $customer_id );
- if ( null !== $status ) {
- $where .= ' AND status = %s';
- $params[] = $status;
+ if ( array() !== $statuses ) {
+ $where .= ' AND status IN (' . implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ) . ')';
+ $params = array_merge( $params, $statuses );
}
$params[] = $limit;
$params[] = $offset;
@@ -834,6 +852,24 @@ final class ContractRepository {
return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
}
+ /**
+ * Contracts whose `origin_order_id` is `$order_id`, oldest first. Row-only reads
+ * (no items / addresses) with their frozen plan terms batch-hydrated.
+ *
+ * @param int $order_id Origin order id.
+ * @return array<int, Contract>
+ */
+ public function find_by_origin_order( int $order_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
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE origin_order_id = %d ORDER BY id ASC", $order_id ), ARRAY_A );
+
+ return $this->contracts_from_rows( is_array( $rows ) ? $rows : array() );
+ }
+
/**
* Whether a contract row exists for `$id`.
*
@@ -878,12 +914,167 @@ final class ContractRepository {
return (bool) $deleted;
}
+ /**
+ * Add a meta row for a contract, like `add_post_meta()`.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @param mixed $value Meta value; serialized when not scalar.
+ * @param bool $unique When true, add nothing if the key already exists. Advisory:
+ * checked before the insert with no unique index.
+ * @return int|null The new meta row id, or null when `$unique` and the key exists.
+ * @throws \InvalidArgumentException If `$key` is empty.
+ * @throws \RuntimeException If the insert fails.
+ */
+ public function add_meta( int $contract_id, string $key, $value, bool $unique = false ): ?int {
+ global $wpdb;
+
+ if ( '' === $key ) {
+ throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
+ }
+
+ if ( $unique && array() !== $this->find_meta_values( $contract_id, $key ) ) {
+ return null;
+ }
+
+ // 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
+ $inserted = $wpdb->insert(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
+ array(
+ 'contract_id' => $contract_id,
+ 'meta_key' => $key,
+ 'meta_value' => maybe_serialize( $value ),
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( sprintf( 'Failed to add contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+ }
+
+ return (int) $wpdb->insert_id;
+ }
+
+ /**
+ * Update a contract's meta rows for `$key`, like `update_post_meta()`: adds a row when
+ * the key is absent, else rewrites every row for the key, or only the rows holding
+ * `$prev_value`. The absent-key check runs before the write with no unique index.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @param mixed $value New value; serialized when not scalar.
+ * @param mixed $prev_value Only update rows holding this value; null updates all rows for the key.
+ * Any other value ('' and false included) matches literally.
+ * @return bool True when a row was added or at least one row changed.
+ * @throws \InvalidArgumentException If `$key` is empty.
+ * @throws \RuntimeException If a write fails.
+ */
+ public function update_meta( int $contract_id, string $key, $value, $prev_value = null ): bool {
+ global $wpdb;
+
+ if ( '' === $key ) {
+ throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
+ }
+
+ if ( array() === $this->find_meta_values( $contract_id, $key ) ) {
+ $this->add_meta( $contract_id, $key, $value );
+ return true;
+ }
+
+ $where = array(
+ 'contract_id' => $contract_id,
+ 'meta_key' => $key,
+ );
+ if ( null !== $prev_value ) {
+ $where['meta_value'] = maybe_serialize( $prev_value );
+ }
+
+ // 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
+ $updated = $wpdb->update(
+ SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
+ array( 'meta_value' => maybe_serialize( $value ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $where
+ );
+
+ if ( false === $updated ) {
+ throw new \RuntimeException( sprintf( 'Failed to update contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+ }
+
+ return $updated > 0;
+ }
+
+ /**
+ * Delete a contract's meta rows for `$key`, like `delete_post_meta()`.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @param mixed $value Only delete rows holding this value; null deletes every row for the key.
+ * Any other value ('' and false included) matches literally.
+ * @return bool True when at least one row was deleted.
+ * @throws \InvalidArgumentException If `$key` is empty.
+ * @throws \RuntimeException If the delete fails.
+ */
+ public function delete_meta( int $contract_id, string $key, $value = null ): bool {
+ global $wpdb;
+
+ if ( '' === $key ) {
+ throw new \InvalidArgumentException( 'Contract meta key must not be empty.' );
+ }
+
+ $where = array(
+ 'contract_id' => $contract_id,
+ 'meta_key' => $key,
+ );
+ if ( null !== $value ) {
+ $where['meta_value'] = maybe_serialize( $value );
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), $where );
+
+ if ( false === $deleted ) {
+ throw new \RuntimeException( sprintf( 'Failed to delete contract meta "%s" for contract %d: %s', esc_html( $key ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+ }
+
+ return $deleted > 0;
+ }
+
+ /**
+ * Read contract meta (WordPress `get_post_meta()` semantics), values unserialized,
+ * oldest row first.
+ *
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key; empty for every key.
+ * @param bool $single With a key: return the first value only.
+ * @return mixed Empty key: `array<string, array<int, mixed>>` of all keys. Key + `$single`:
+ * the first value, or '' when absent. Key only: the list of values (`[]` when absent).
+ */
+ public function get_meta( int $contract_id, string $key = '', bool $single = false ) {
+ if ( '' === $key ) {
+ $all = array();
+ foreach ( $this->find_meta_rows( $contract_id, null ) as $row ) {
+ $all[ $row['meta_key'] ][] = maybe_unserialize( $row['meta_value'] );
+ }
+
+ return $all;
+ }
+
+ $values = $this->find_meta_values( $contract_id, $key );
+
+ if ( $single ) {
+ return array() === $values ? '' : $values[0];
+ }
+
+ return $values;
+ }
+
/**
* 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.
+ * Assigns an unassigned `sequence_no` (the chain head's plus one, or 1), 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 chain's
+ * UNIQUE indexes guard concurrent appends and duplicate counts. 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
@@ -894,10 +1085,25 @@ final class ContractRepository {
* other reason (the database error is in the message).
*/
public function append_cycle( Cycle $cycle, ?Cycle $previous = null ): void {
+ $this->assign_next_sequence_no( $cycle );
$this->resolve_cycle_snapshots( $cycle, $previous );
$this->insert_cycle( $cycle );
}
+ /**
+ * Assign the chain's next `sequence_no` to a cycle that awaits one.
+ *
+ * @param Cycle $cycle Cycle about to be appended.
+ */
+ private function assign_next_sequence_no( Cycle $cycle ): void {
+ if ( ! $cycle->awaits_sequence_no() ) {
+ return;
+ }
+
+ $head = $this->find_chain_head( $cycle->get_contract_id(), $cycle->get_kind() );
+ $cycle->assign_sequence_no( null === $head ? 1 : $head->get_sequence_no() + 1 );
+ }
+
/**
* Update an existing cycle row.
*
@@ -1069,33 +1275,13 @@ final class ContractRepository {
$cycles = array();
foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
- $cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+ $cycles[] = $this->hydrate_cycle( Coercion::coerce_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
@@ -1115,7 +1301,7 @@ final class ContractRepository {
$cycles = array();
foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
- $cycles[] = $this->hydrate_cycle( self::as_string_keyed( $row ) );
+ $cycles[] = $this->hydrate_cycle( Coercion::coerce_string_keyed( $row ) );
}
}
@@ -1273,7 +1459,7 @@ final class ContractRepository {
return null;
}
- return PlanSnapshot::from_payload( self::as_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
+ return PlanSnapshot::from_payload( Coercion::coerce_string_keyed( $decoded['payload'] ), $decoded['schema_version'] );
}
/**
@@ -1313,11 +1499,11 @@ final class ContractRepository {
if ( ! is_array( $row ) ) {
continue;
}
- $payload = json_decode( ScalarCoercion::coerce_string( $row['payload'] ?? null ), true );
+ $payload = json_decode( Coercion::coerce_string( $row['payload'] ?? null ), true );
- $snapshots[ ScalarCoercion::coerce_int( $row['id'] ?? 0 ) ] = PlanSnapshot::from_payload(
- self::as_string_keyed( is_array( $payload ) ? $payload : array() ),
- ScalarCoercion::coerce_int( $row['schema_version'] ?? 0 )
+ $snapshots[ Coercion::coerce_int( $row['id'] ?? 0 ) ] = PlanSnapshot::from_payload(
+ Coercion::coerce_string_keyed( is_array( $payload ) ? $payload : array() ),
+ Coercion::coerce_int( $row['schema_version'] ?? 0 )
);
}
@@ -1336,7 +1522,7 @@ final class ContractRepository {
return null;
}
- return ItemsSnapshot::from_payload( self::as_item_rows( $decoded['payload'] ), $decoded['schema_version'] );
+ return ItemsSnapshot::from_payload( Coercion::coerce_list_of_arrays( $decoded['payload'] ), $decoded['schema_version'] );
}
/**
@@ -1369,42 +1555,6 @@ final class ContractRepository {
);
}
- /**
- * 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.
*
@@ -1467,7 +1617,7 @@ final class ContractRepository {
}
/**
- * Reconcile a contract's items, addresses, and meta rows only when they differ.
+ * Reconcile a contract's items and address 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
@@ -1475,6 +1625,7 @@ final class ContractRepository {
* 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.
+ * @throws \RuntimeException If a child row write fails.
*/
private function sync_children( Contract $contract ): void {
$id = (int) $contract->get_id();
@@ -1486,10 +1637,6 @@ final class ContractRepository {
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() );
- }
}
/**
@@ -1497,12 +1644,17 @@ final class ContractRepository {
*
* @param int $contract_id Contract id.
* @param array<int, array<string, mixed>> $items Item rows.
+ * @throws \RuntimeException If the delete or an insert fails.
*/
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 ) );
+ $deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ), array( 'contract_id' => $contract_id ) );
+ if ( false === $deleted ) {
+ throw new \RuntimeException( sprintf( 'Failed to delete item rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+ }
+
$this->insert_items( $contract_id, $items );
}
@@ -1511,29 +1663,18 @@ final class ContractRepository {
*
* @param int $contract_id Contract id.
* @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
+ * @throws \RuntimeException If the delete or an insert fails.
*/
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
- if ( false === $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ), array( 'contract_id' => $contract_id ) ) ) {
- $this->log_meta_write_failure( $contract_id, 'delete' );
+ $deleted = $wpdb->delete( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), array( 'contract_id' => $contract_id ) );
+ if ( false === $deleted ) {
+ throw new \RuntimeException( sprintf( 'Failed to delete address rows for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
}
- $this->insert_meta( $contract_id, $meta );
+
+ $this->insert_addresses( $contract_id, $addresses );
}
/**
@@ -1541,26 +1682,31 @@ final class ContractRepository {
*
* @param int $contract_id Contract id.
* @param array<int, array<string, mixed>> $items Item rows.
+ * @throws \RuntimeException If an insert fails.
*/
private function insert_items( int $contract_id, array $items ): void {
global $wpdb;
foreach ( $items as $item ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->insert(
+ $inserted = $wpdb->insert(
SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
array(
'contract_id' => $contract_id,
- 'item_name' => ScalarCoercion::coerce_string( $item['item_name'] ?? null ),
- 'item_type' => ScalarCoercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
- 'product_id' => isset( $item['product_id'] ) ? ScalarCoercion::coerce_int( $item['product_id'] ) : null,
- 'variation_id' => isset( $item['variation_id'] ) ? ScalarCoercion::coerce_int( $item['variation_id'] ) : null,
- 'quantity' => ScalarCoercion::coerce_string( $item['quantity'] ?? null, '1' ),
- 'subtotal' => ScalarCoercion::coerce_string( $item['subtotal'] ?? null, '0' ),
- 'total' => ScalarCoercion::coerce_string( $item['total'] ?? null, '0' ),
+ 'item_name' => Coercion::coerce_string( $item['item_name'] ?? null ),
+ 'item_type' => Coercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+ 'product_id' => isset( $item['product_id'] ) ? Coercion::coerce_int( $item['product_id'] ) : null,
+ 'variation_id' => isset( $item['variation_id'] ) ? Coercion::coerce_int( $item['variation_id'] ) : null,
+ 'quantity' => Coercion::coerce_string( $item['quantity'] ?? null, '1' ),
+ 'subtotal' => Coercion::coerce_string( $item['subtotal'] ?? null, '0' ),
+ 'total' => Coercion::coerce_string( $item['total'] ?? null, '0' ),
'taxes' => isset( $item['taxes'] ) ? wp_json_encode( $item['taxes'] ) : null,
)
);
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( sprintf( 'Failed to insert item row for contract %d: %s', (int) $contract_id, esc_html( $wpdb->last_error ) ) );
+ }
}
}
@@ -1569,6 +1715,7 @@ final class ContractRepository {
*
* @param int $contract_id Contract id.
* @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
+ * @throws \RuntimeException If an insert fails.
*/
private function insert_addresses( int $contract_id, array $addresses ): void {
global $wpdb;
@@ -1579,63 +1726,18 @@ final class ContractRepository {
'address_type' => (string) $type,
);
- foreach ( self::ADDRESS_COLUMNS as $column ) {
- $record[ $column ] = isset( $address[ $column ] ) ? ScalarCoercion::coerce_string( $address[ $column ] ) : null;
+ foreach ( Contract::ADDRESS_FIELDS as $column ) {
+ $record[ $column ] = isset( $address[ $column ] ) ? Coercion::coerce_string( $address[ $column ] ) : null;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), $record );
- }
- }
-
- /**
- * Insert meta for a contract.
- *
- * @param int $contract_id Contract id.
- * @param array<string, string> $meta Meta as key => value.
- */
- private function insert_meta( int $contract_id, array $meta ): void {
- global $wpdb;
-
- foreach ( $meta as $key => $value ) {
- // 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
- $inserted = $wpdb->insert(
- SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META ),
- array(
- 'contract_id' => $contract_id,
- 'meta_key' => (string) $key,
- 'meta_value' => (string) $value,
- )
- );
+ $inserted = $wpdb->insert( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ADDRESSES ), $record );
if ( false === $inserted ) {
- $this->log_meta_write_failure( $contract_id, 'insert', (string) $key );
+ throw new \RuntimeException( sprintf( 'Failed to insert %s address row for contract %d: %s', esc_html( (string) $type ), (int) $contract_id, esc_html( $wpdb->last_error ) ) );
}
}
}
- /**
- * Log a failed contract-meta write. Meta writes follow the row write without a
- * transaction, so a failure here leaves the row and its meta out of step; logging it
- * makes that visible.
- *
- * @param int $contract_id Contract id.
- * @param string $operation The failed operation (`delete` or `insert`).
- * @param string $meta_key The meta key being inserted, if any.
- */
- private function log_meta_write_failure( int $contract_id, string $operation, string $meta_key = '' ): void {
- global $wpdb;
-
- wc_get_logger()->error(
- sprintf( 'ContractRepository: contract meta %s failed for contract %d%s - %s', $operation, $contract_id, '' === $meta_key ? '' : sprintf( ' (key %s)', $meta_key ), $wpdb->last_error ),
- array(
- 'source' => self::LOG_SOURCE,
- 'contract_id' => $contract_id,
- )
- );
- }
-
/**
* Load line items for a contract.
*
@@ -1650,7 +1752,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 );
- return self::as_item_rows( is_array( $rows ) ? $rows : array() );
+ return Coercion::coerce_list_of_arrays( $rows );
}
/**
@@ -1670,7 +1772,7 @@ final class ContractRepository {
$by_type = array();
foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
- $by_type[ ScalarCoercion::coerce_string( $row['address_type'] ?? null ) ] = self::as_string_keyed( $row );
+ $by_type[ Coercion::coerce_string( $row['address_type'] ?? null ) ] = Coercion::coerce_string_keyed( $row );
}
}
@@ -1678,29 +1780,54 @@ final class ContractRepository {
}
/**
- * Load meta for a contract as key => value.
+ * Unserialized values stored under `$key` for a contract, oldest first.
*
- * @param int $contract_id Contract id.
- * @return array<string, string>
+ * @param int $contract_id Contract id.
+ * @param string $key Meta key.
+ * @return array<int, mixed>
+ */
+ private function find_meta_values( int $contract_id, string $key ): array {
+ $values = array();
+ foreach ( $this->find_meta_rows( $contract_id, $key ) as $row ) {
+ $values[] = maybe_unserialize( $row['meta_value'] );
+ }
+
+ return $values;
+ }
+
+ /**
+ * Raw meta rows for a contract, optionally for one key, by id ascending.
+ *
+ * @param int $contract_id Contract id.
+ * @param string|null $key Meta key, or null for every key.
+ * @return array<int, array{meta_key: string, meta_value: string}>
*/
- private function find_meta( int $contract_id ): array {
+ private function find_meta_rows( int $contract_id, ?string $key ): array {
global $wpdb;
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
- // 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 );
+ if ( null === $key ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d ORDER BY id ASC", $contract_id ), ARRAY_A );
+ } else {
+ // 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
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d AND meta_key = %s ORDER BY id ASC", $contract_id, $key ), ARRAY_A );
+ }
- $meta = array();
+ $result = array();
foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
- $meta[ ScalarCoercion::coerce_string( $row['meta_key'] ?? null ) ] = ScalarCoercion::coerce_string( $row['meta_value'] ?? null );
+ $result[] = array(
+ 'meta_key' => Coercion::coerce_string( $row['meta_key'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => Coercion::coerce_string( $row['meta_value'] ?? null ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ );
}
}
- return $meta;
+ return $result;
}
/**
@@ -1717,13 +1844,13 @@ final class ContractRepository {
foreach ( $items as $item ) {
$signature[] = array(
- 'item_name' => ScalarCoercion::coerce_string( $item['item_name'] ?? null ),
- 'item_type' => ScalarCoercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
- 'product_id' => isset( $item['product_id'] ) ? (string) ScalarCoercion::coerce_int( $item['product_id'] ) : null,
- 'variation_id' => isset( $item['variation_id'] ) ? (string) ScalarCoercion::coerce_int( $item['variation_id'] ) : null,
- 'quantity' => number_format( ScalarCoercion::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
- 'subtotal' => number_format( ScalarCoercion::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
- 'total' => number_format( ScalarCoercion::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
+ 'item_name' => Coercion::coerce_string( $item['item_name'] ?? null ),
+ 'item_type' => Coercion::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+ 'product_id' => isset( $item['product_id'] ) ? (string) Coercion::coerce_int( $item['product_id'] ) : null,
+ 'variation_id' => isset( $item['variation_id'] ) ? (string) Coercion::coerce_int( $item['variation_id'] ) : null,
+ 'quantity' => number_format( Coercion::coerce_float( $item['quantity'] ?? 1 ), 4, '.', '' ),
+ 'subtotal' => number_format( Coercion::coerce_float( $item['subtotal'] ?? 0 ), 8, '.', '' ),
+ 'total' => number_format( Coercion::coerce_float( $item['total'] ?? 0 ), 8, '.', '' ),
'taxes' => $this->taxes_signature( $item['taxes'] ?? null ),
);
}
@@ -1746,8 +1873,8 @@ final class ContractRepository {
foreach ( $addresses as $type => $address ) {
$record = array();
- foreach ( self::ADDRESS_COLUMNS as $column ) {
- $value = isset( $address[ $column ] ) ? ScalarCoercion::coerce_string( $address[ $column ] ) : '';
+ foreach ( Contract::ADDRESS_FIELDS as $column ) {
+ $value = isset( $address[ $column ] ) ? Coercion::coerce_string( $address[ $column ] ) : '';
$record[ $column ] = '' !== $value ? $value : null;
}
@@ -1759,24 +1886,6 @@ final class ContractRepository {
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.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
index bb5bd1c11de..dead97a62cb 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/PlanRepository.php
@@ -10,7 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -151,8 +151,8 @@ final class PlanRepository {
$table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_PLANS );
$order = $this->build_order_clause( $args );
- $limit = max( 1, ScalarCoercion::coerce_int( $args['limit'] ?? null, 50 ) );
- $offset = max( 0, ScalarCoercion::coerce_int( $args['offset'] ?? null, 0 ) );
+ $limit = max( 1, Coercion::coerce_int( $args['limit'] ?? null, 50 ) );
+ $offset = max( 0, Coercion::coerce_int( $args['offset'] ?? null, 0 ) );
// phpcs:ignore Generic.Arrays.DisallowShortArraySyntax.Found
[
@@ -311,7 +311,7 @@ final class PlanRepository {
? array_unique(
array_map(
static function ( $matched_id ): int {
- return ScalarCoercion::coerce_int( $matched_id );
+ return Coercion::coerce_int( $matched_id );
},
$matched_ids
)
@@ -353,7 +353,7 @@ final class PlanRepository {
$clauses = array();
$params = array();
- $status = ScalarCoercion::coerce_string( $args['status'] ?? null );
+ $status = Coercion::coerce_string( $args['status'] ?? null );
if ( '' !== $status ) {
$clauses[] = 'status = %s';
$params[] = $status;
@@ -402,7 +402,7 @@ final class PlanRepository {
$ids = array();
$all_valid = true;
foreach ( array_values( $args['ids'] ) as $possible_id ) {
- $plan_id = ScalarCoercion::coerce_int( $possible_id );
+ $plan_id = Coercion::coerce_int( $possible_id );
if ( $plan_id <= 0 ) {
$all_valid = false;
break;
@@ -425,7 +425,7 @@ final class PlanRepository {
}
}
- $search = ScalarCoercion::coerce_string( $args['search'] ?? null );
+ $search = Coercion::coerce_string( $args['search'] ?? null );
if ( '' !== $search ) {
$like = '%' . $wpdb->esc_like( $search ) . '%';
$clauses[] = '(name LIKE %s OR description LIKE %s)';
@@ -452,11 +452,11 @@ final class PlanRepository {
* @param array<string, mixed> $args Query args.
*/
private function build_order_clause( array $args ): string {
- $orderby_arg = ScalarCoercion::coerce_string( $args['orderby'] ?? null );
+ $orderby_arg = Coercion::coerce_string( $args['orderby'] ?? null );
$orderby = isset( self::ORDERBY_COLUMNS[ $orderby_arg ] )
? self::ORDERBY_COLUMNS[ $orderby_arg ]
: 'sort_order';
- $order = 'desc' === strtolower( ScalarCoercion::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';
+ $order = 'desc' === strtolower( Coercion::coerce_string( $args['order'] ?? null ) ) ? 'DESC' : 'ASC';
if ( 'sort_order' === $orderby ) {
return "ORDER BY sort_order {$order}, id ASC";
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 522815db895..c2bf1c54a99 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/SchemaInstaller.php
@@ -43,13 +43,16 @@ final class SchemaInstaller {
* `due_contract (status, next_payment_gmt)` and makes the contracts
* `extension_slug` index redundant (dropped); pre-freeze, existing tables must be
* recreated to drop the old indexes.
+ * 2.5.0 - contracts `customer_id`, `currency`, `selling_plan_id`, `start_gmt` nullable;
+ * contract_meta indexes `meta_key_value` and `contract_meta_key_value` (HPOS
+ * shape) replace `contract_key`; pre-freeze, recreate the tables.
*
* 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.
*/
- private const VERSION = '2.4.0';
+ private const VERSION = '2.5.0';
/**
* Option key tracking the installed schema version.
@@ -229,7 +232,8 @@ final class SchemaInstaller {
// values, not caches of cycles. The `due_owner (extension_slug, next_payment_gmt)` index
// keys the batch dispatcher's scan (owner equality, then a range on the due date);
// `due` is retained for next-bill-cache lookups keyed the other way. `origin_order_id`
- // is NULLABLE (a manual/admin contract has no origin order). There is no generic
+ // is NULLABLE (a manual/admin contract has no origin order); `customer_id`, `currency`,
+ // `selling_plan_id` and `start_gmt` are NULLABLE until the extension supplies them. 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
@@ -237,15 +241,15 @@ final class SchemaInstaller {
$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,
+ customer_id BIGINT UNSIGNED NULL,
+ currency CHAR(3) NULL,
+ selling_plan_id BIGINT UNSIGNED 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,
+ start_gmt DATETIME NULL,
next_payment_gmt DATETIME NULL,
plan_snapshot_id BIGINT UNSIGNED NULL,
items_snapshot_id BIGINT UNSIGNED NULL,
@@ -301,13 +305,17 @@ final class SchemaInstaller {
PRIMARY KEY (contract_id, address_type)
) {$collate};";
+ // Mirrors the HPOS orders meta table indexes, including its meta_value prefix length.
+ $meta_value_index_length = max( min( absint( apply_filters( 'woocommerce_database_max_index_length', 191 ) ), 767 ) - 8 - 100 - 1, 20 );
+
$contract_meta_sql = "CREATE TABLE {$contract_meta} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
contract_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
meta_value LONGTEXT NULL,
PRIMARY KEY (id),
- KEY contract_key (contract_id, meta_key(100))
+ KEY meta_key_value (meta_key(50), meta_value(20)),
+ KEY contract_meta_key_value (contract_id, meta_key(100), meta_value({$meta_value_index_length}))
) {$collate};";
// Immutable billing records. A chain is the pair `(contract_id, kind)` - there is
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
new file mode 100644
index 00000000000..ad8aa3a6cae
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
@@ -0,0 +1,236 @@
+<?php
+/**
+ * Argument validators shared by the public write facades.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use DateTimeZone;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validate caller argument values and return them normalized.
+ *
+ * @internal Engine implementation detail shared by the `Api\` write facades, not part of the public API.
+ */
+final class ArgumentValidator {
+
+ /**
+ * Keep the keys in `$allowed`; each other key raises a `_doing_it_wrong()` notice and is dropped.
+ *
+ * @param string $method Public facade method, for the notice.
+ * @param array<int|string, mixed> $args Caller arguments.
+ * @param array<string, true> $allowed Accepted keys, as a key map.
+ * @param string $what What the keys belong to, for the notice.
+ * @return array<string, mixed> The arguments with known keys only.
+ */
+ public static function filter_known_keys( string $method, array $args, array $allowed, string $what = 'key' ): array {
+ $filtered = array();
+ foreach ( $args as $key => $value ) {
+ if ( isset( $allowed[ $key ] ) ) {
+ $filtered[ (string) $key ] = $value;
+ continue;
+ }
+
+ _doing_it_wrong(
+ esc_html( $method ),
+ sprintf( 'Unknown %s "%s" ignored.', esc_html( $what ), esc_html( (string) $key ) ),
+ '0.0.1'
+ );
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * Validate and return a currency code, or null.
+ *
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not null or a three-letter uppercase code.
+ */
+ public static function validate_currency( $value ): ?string {
+ if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}$/', $value ) ) ) {
+ throw new InvalidArgumentException( '"currency" must be null or a three-letter uppercase ISO-4217 code.' );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a string.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not a string.
+ */
+ public static function validate_string( string $key, $value ): string {
+ if ( ! is_string( $value ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a string.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a string, or null.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not null or a string.
+ */
+ public static function validate_nullable_string( string $key, $value ): ?string {
+ if ( null !== $value && ! is_string( $value ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be null or a string.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate and return a positive integer id (a digit string is cast), or null.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @throws InvalidArgumentException If the value is not null or a positive integer.
+ */
+ public static function validate_nullable_id( string $key, $value ): ?int {
+ if ( null === $value ) {
+ return null;
+ }
+
+ if ( is_string( $value ) && 1 === preg_match( '/^[0-9]+$/', $value ) ) {
+ $value = (int) $value;
+ }
+
+ if ( ! is_int( $value ) || $value <= 0 ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be null or a positive integer.', esc_html( $key ) ) );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
+ *
+ * @param string $key Field name.
+ * @param mixed $value `DateTimeInterface`, GMT `Y-m-d H:i:s` string, or null.
+ * @throws InvalidArgumentException If the value is not a valid datetime.
+ */
+ public static function validate_nullable_date( string $key, $value ): ?string {
+ if ( null === $value ) {
+ return null;
+ }
+
+ if ( $value instanceof DateTimeInterface ) {
+ return ( new DateTimeImmutable( '@' . $value->getTimestamp() ) )->format( 'Y-m-d H:i:s' );
+ }
+
+ if ( is_string( $value ) ) {
+ $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new DateTimeZone( 'UTC' ) );
+ if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $value ) {
+ return $value;
+ }
+ }
+
+ throw new InvalidArgumentException( sprintf( '"%s" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.', esc_html( $key ) ) );
+ }
+
+ /**
+ * Validate a money value and return it normalized to the storage scale; null is 0.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Number, numeric string, or null.
+ * @throws InvalidArgumentException If the value is not numeric.
+ */
+ public static function validate_money( string $key, $value ): string {
+ if ( null === $value ) {
+ return MoneyScale::normalize_money( 0 );
+ }
+
+ if ( ! is_int( $value ) && ! is_float( $value ) && ! ( is_string( $value ) && is_numeric( $value ) ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a number or a numeric string.', esc_html( $key ) ) );
+ }
+
+ return MoneyScale::normalize_money( $value );
+ }
+
+ /**
+ * Validate a list of arrays and return it with string-keyed rows.
+ *
+ * @param string $key Field name.
+ * @param mixed $value Caller value.
+ * @return array<int, array<string, mixed>>
+ * @throws InvalidArgumentException If the value is not a list of arrays.
+ */
+ public static function validate_list_of_arrays( string $key, $value ): array {
+ if ( ! is_array( $value ) || ( array() !== $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
+ }
+
+ $rows = array();
+ foreach ( $value as $row ) {
+ if ( ! is_array( $row ) ) {
+ throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
+ }
+ $rows[] = Coercion::coerce_string_keyed( $row );
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Validate contract item rows; unknown row keys are dropped with a notice.
+ *
+ * @param mixed $value Caller value.
+ * @param string $function_name Function named in the notice; defaults to this method.
+ * @return array<int, array<string, mixed>>
+ * @throws InvalidArgumentException If the value is not a list of item rows.
+ */
+ public static function validate_contract_items( $value, string $function_name = __METHOD__ ): array {
+ $allowed = array_fill_keys( Contract::ITEM_FIELDS, true );
+ $rows = array();
+ foreach ( self::validate_list_of_arrays( 'items', $value ) as $row ) {
+ $rows[] = self::filter_known_keys( $function_name, $row, $allowed, 'item key' );
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Validate contract addresses keyed `billing` / `shipping`; unknown address keys are
+ * dropped with a notice.
+ *
+ * @param mixed $value Caller value.
+ * @param string $function_name Function named in the notice; defaults to this method.
+ * @return array<string, array<string, mixed>>
+ * @throws InvalidArgumentException If the map is not keyed `billing` / `shipping` with array values.
+ */
+ public static function validate_contract_addresses( $value, string $function_name = __METHOD__ ): array {
+ if ( ! is_array( $value ) ) {
+ throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping".' );
+ }
+
+ $allowed = array_fill_keys( Contract::ADDRESS_FIELDS, true );
+ $addresses = array();
+ foreach ( $value as $type => $address ) {
+ if ( ! in_array( $type, array( Contract::ADDRESS_BILLING, Contract::ADDRESS_SHIPPING ), true ) || ! is_array( $address ) ) {
+ throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
+ }
+
+ $addresses[ $type ] = self::filter_known_keys( $function_name, $address, $allowed, 'address key' );
+ }
+
+ return $addresses;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
new file mode 100644
index 00000000000..e7ff1730f5c
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -0,0 +1,1435 @@
+<?php
+/**
+ * Integration tests for the public Contracts facade.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use DomainException;
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
+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\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts
+ */
+class ContractsTest extends EngineIntegrationTestCase {
+
+ private const EXTENSION_SLUG = 'acme-subs';
+
+ public function tear_down(): void {
+ StatusRegistry::reset();
+ parent::tear_down();
+ }
+
+ /**
+ * Read a contract back as a view, asserting it exists.
+ *
+ * @param int $id Contract id.
+ */
+ private function view( int $id ): ContractView {
+ $view = Contracts::get( $id );
+ $this->assertInstanceOf( ContractView::class, $view );
+
+ return $view;
+ }
+
+ /**
+ * Read a contract back as the entity, asserting it exists.
+ *
+ * @param int $id Contract id.
+ */
+ private function entity( int $id ): Contract {
+ $contract = ( new ContractRepository() )->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+
+ return $contract;
+ }
+
+ public function test_an_extension_slug_only_create_is_an_empty_draft(): void {
+ $created = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) );
+ $id = $created->get_id();
+
+ $view = $this->view( $id );
+ $this->assertEquals( $view, $created, 'The returned view matches a fresh read.' );
+ $this->assertSame( ContractStatus::DRAFT, $view->get_status() );
+ $this->assertSame( self::EXTENSION_SLUG, $view->get_extension_slug() );
+ $this->assertNull( $view->get_customer_id() );
+ $this->assertNull( $view->get_currency() );
+ $this->assertNull( $view->get_selling_plan_id() );
+ $this->assertNull( $view->get_start_gmt() );
+ $this->assertNull( $view->get_next_payment_gmt() );
+ $this->assertSame( '0.00000000', $view->get_billing_total() );
+ $this->assertSame( '0.00000000', $view->get_tax_total() );
+ $this->assertSame( array(), $view->get_items() );
+ }
+
+ public function test_create_stores_and_returns_every_field(): void {
+ $created = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => ContractStatus::ACTIVE,
+ 'customer_id' => '12',
+ 'currency' => 'EUR',
+ 'selling_plan_id' => 3,
+ 'origin_order_id' => 4,
+ 'payment_method' => 'dummy',
+ 'payment_method_title' => 'Dummy',
+ 'payment_token_id' => 5,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ 'last_payment_gmt' => '2026-01-02 00:00:00',
+ 'last_attempt_gmt' => '2026-01-03 00:00:00',
+ 'trial_end_gmt' => '2026-01-04 00:00:00',
+ 'end_gmt' => '2027-01-01 00:00:00',
+ 'schedule_source' => Contract::SCHEDULE_SOURCE_GATEWAY,
+ 'billing_total' => 20,
+ 'discount_total' => '1.5',
+ 'shipping_total' => 5.25,
+ 'tax_total' => '2',
+ 'items' => array(
+ array(
+ 'item_name' => 'Coffee',
+ 'product_id' => 9,
+ 'quantity' => '2',
+ 'total' => '20',
+ ),
+ ),
+ 'addresses' => array(
+ 'billing' => array(
+ 'first_name' => 'Ada',
+ 'country' => 'PT',
+ ),
+ ),
+ )
+ );
+
+ $id = $created->get_id();
+ $this->assertGreaterThan( 0, $id );
+
+ foreach ( array( $created, $this->view( $id ) ) as $view ) {
+ $this->assertSame( ContractStatus::ACTIVE, $view->get_status() );
+ $this->assertSame( 12, $view->get_customer_id() );
+ $this->assertSame( 'EUR', $view->get_currency() );
+ $this->assertSame( 3, $view->get_selling_plan_id() );
+ $this->assertSame( 4, $view->get_origin_order_id() );
+ $this->assertSame( 'dummy', $view->get_payment_method() );
+ $this->assertSame( 'Dummy', $view->get_payment_method_title() );
+ $this->assertSame( 5, $view->get_payment_token_id() );
+ $this->assertSame( '2026-01-01 00:00:00', $view->get_start_gmt() );
+ $this->assertSame( '2026-02-01 00:00:00', $view->get_next_payment_gmt() );
+ $this->assertSame( '2026-01-02 00:00:00', $view->get_last_payment_gmt() );
+ $this->assertSame( '2026-01-03 00:00:00', $view->get_last_attempt_gmt() );
+ $this->assertSame( '2026-01-04 00:00:00', $view->get_trial_end_gmt() );
+ $this->assertSame( '2027-01-01 00:00:00', $view->get_end_gmt() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $view->get_schedule_source() );
+ $this->assertSame( '20.00000000', $view->get_billing_total() );
+ $this->assertSame( '1.50000000', $view->get_discount_total() );
+ $this->assertSame( '5.25000000', $view->get_shipping_total() );
+ $this->assertSame( '2.00000000', $view->get_tax_total() );
+
+ $items = $view->get_items();
+ $this->assertIsArray( $items );
+ $this->assertCount( 1, $items );
+ $this->assertSame( 'Coffee', $items[0]['item_name'] );
+
+ $addresses = $view->get_addresses();
+ $this->assertIsArray( $addresses );
+ $this->assertSame( 'Ada', $addresses['billing']['first_name'] ?? null );
+ }
+ }
+
+ public function test_items_and_addresses_read_back_in_the_written_shape(): void {
+ $items = array(
+ array(
+ 'item_name' => 'Coffee',
+ 'item_type' => 'line_item',
+ 'product_id' => 9,
+ 'variation_id' => 10,
+ 'quantity' => '2.0000',
+ 'subtotal' => '20.00000000',
+ 'total' => '18.00000000',
+ 'taxes' => array(
+ 'total' => array( 1 => '1.80' ),
+ 'subtotal' => array( 1 => '2.00' ),
+ ),
+ ),
+ );
+ $billing = array_fill_keys( Contract::ADDRESS_FIELDS, null );
+ $addresses = array(
+ 'billing' => array_merge(
+ $billing,
+ array(
+ 'first_name' => 'Ada',
+ 'country' => 'PT',
+ )
+ ),
+ 'shipping' => array_merge( $billing, array( 'city' => 'Porto' ) ),
+ );
+
+ $created = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'items' => $items,
+ 'addresses' => $addresses,
+ )
+ );
+ $id = $created->get_id();
+
+ $view = $this->view( $id );
+ $this->assertEquals( $view, $created, 'The returned view matches a fresh read.' );
+ $this->assertSame( $items, $view->get_items() );
+ $this->assertEquals( $addresses, $view->get_addresses() );
+
+ $listed = Contracts::list( array( 'search' => (string) $id ) );
+ $this->assertSame( array( $id ), array_map( static fn( ContractView $row ): int => $row->get_id(), $listed ) );
+ $this->assertNull( $listed[0]->get_items() );
+ $this->assertNull( $listed[0]->get_addresses() );
+ }
+
+ public function test_datetime_objects_are_stored_as_utc_strings(): void {
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'start_gmt' => new DateTimeImmutable( '2026-01-01 02:00:00', new DateTimeZone( 'Europe/Lisbon' ) ),
+ 'next_payment_gmt' => new DateTimeImmutable( '2026-07-01 02:00:00', new DateTimeZone( 'Europe/Lisbon' ) ),
+ )
+ )->get_id();
+
+ $view = $this->view( $id );
+ $this->assertSame( '2026-01-01 02:00:00', $view->get_start_gmt() );
+ $this->assertSame( '2026-07-01 01:00:00', $view->get_next_payment_gmt() );
+ }
+
+ public function test_an_unknown_key_is_ignored_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( Contracts::class . '::create' );
+ $messages = array();
+ add_action(
+ 'doing_it_wrong_run',
+ static function ( $function_name, $message ) use ( &$messages ): void {
+ $messages[] = $message;
+ },
+ 10,
+ 2
+ );
+
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'custmer_id' => 1,
+ 'customer_id' => 7,
+ )
+ )->get_id();
+
+ $this->assertSame( array( 'Unknown key "custmer_id" ignored.' ), $messages );
+ $this->assertSame( 7, $this->view( $id )->get_customer_id(), 'The known key beside the unknown one is written.' );
+ }
+
+ public function test_unknown_item_and_address_keys_are_ignored_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( Contracts::class );
+
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'items' => array(
+ array(
+ 'item_name' => 'Coffee',
+ 'price' => '1',
+ ),
+ ),
+ 'addresses' => array(
+ 'billing' => array(
+ 'first_name' => 'Ada',
+ 'zip' => '1',
+ ),
+ ),
+ )
+ )->get_id();
+
+ $contract = $this->entity( $id );
+ $items = $contract->get_items();
+ $this->assertCount( 1, $items );
+ $this->assertSame( 'Coffee', $items[0]['item_name'] );
+ $this->assertArrayNotHasKey( 'price', $items[0] );
+ $billing = $contract->get_addresses()['billing'];
+ $this->assertSame( 'Ada', $billing['first_name'] );
+ $this->assertArrayNotHasKey( 'zip', $billing );
+ }
+
+ /**
+ * @dataProvider provide_bad_extension_slugs
+ *
+ * @param array<string, mixed> $args Create args.
+ */
+ public function test_a_missing_or_empty_extension_slug_is_rejected( array $args ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Contracts::create( $args );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_bad_extension_slugs(): array {
+ return array(
+ 'missing' => array( array() ),
+ 'empty' => array( array( 'extension_slug' => '' ) ),
+ 'not string' => array( array( 'extension_slug' => 5 ) ),
+ );
+ }
+
+ public function test_an_entity_invariant_failure_is_reported_as_invalid_input(): void {
+ try {
+ Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => 'nonsense',
+ )
+ );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertInstanceOf( DomainException::class, $e->getPrevious() );
+ $this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
+ }
+ }
+
+ /**
+ * @dataProvider provide_invalid_fields
+ *
+ * @param array<string, mixed> $fields Invalid fields.
+ */
+ public function test_invalid_values_are_rejected_without_a_write( array $fields ): void {
+ $before = Contracts::count();
+
+ try {
+ Contracts::create( array_merge( array( 'extension_slug' => self::EXTENSION_SLUG ), $fields ) );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertSame( $before, Contracts::count(), 'Nothing is written.' );
+ }
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_fields(): array {
+ return array(
+ 'unregistered status' => array( array( 'status' => 'nonsense' ) ),
+ 'lowercase currency' => array( array( 'currency' => 'usd' ) ),
+ 'long currency' => array( array( 'currency' => 'USDX' ) ),
+ 'zero customer' => array( array( 'customer_id' => 0 ) ),
+ 'negative plan' => array( array( 'selling_plan_id' => -3 ) ),
+ 'non-numeric token' => array( array( 'payment_token_id' => 'abc' ) ),
+ 'malformed date' => array( array( 'start_gmt' => '2026-01-01' ) ),
+ 'impossible date' => array( array( 'end_gmt' => '2026-02-30 00:00:00' ) ),
+ 'non-numeric money' => array(
+ array(
+ 'currency' => 'USD',
+ 'billing_total' => 'ten',
+ ),
+ ),
+ 'money without currency' => array( array( 'billing_total' => '10' ) ),
+ 'bad schedule source' => array( array( 'schedule_source' => 'cron' ) ),
+ 'non-string method' => array( array( 'payment_method' => 5 ) ),
+ 'items not a list' => array( array( 'items' => array( 'a' => array() ) ) ),
+ 'unknown address type' => array( array( 'addresses' => array( 'home' => array() ) ) ),
+ );
+ }
+
+ /**
+ * A rejected update writes none of its keys, including the valid ones beside the bad one.
+ *
+ * @dataProvider provide_invalid_update_fields
+ *
+ * @param array<string, mixed> $fields Invalid fields.
+ */
+ public function test_a_rejected_update_writes_none_of_its_keys( array $fields ): void {
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'currency' => 'USD',
+ 'payment_method' => 'dummy',
+ 'billing_total' => '10',
+ )
+ )->get_id();
+
+ try {
+ Contracts::update(
+ $id,
+ array_merge(
+ array(
+ 'payment_method' => 'other',
+ 'billing_total' => '25',
+ ),
+ $fields
+ )
+ );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $view = $this->view( $id );
+ $this->assertSame( 'dummy', $view->get_payment_method(), 'The valid key beside the bad one is not written.' );
+ $this->assertSame( '10.00000000', $view->get_billing_total() );
+ }
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_update_fields(): array {
+ return array(
+ 'unregistered status' => array( array( 'status' => 'nonsense' ) ),
+ 'malformed date' => array( array( 'end_gmt' => '2026-01-01' ) ),
+ );
+ }
+
+ public function test_money_without_currency_is_rejected_on_update(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->expectException( InvalidArgumentException::class );
+
+ Contracts::update( $id, array( 'billing_total' => '10' ) );
+ }
+
+ public function test_a_null_money_value_resets_to_zero_without_a_currency(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'billing_total' => null ) ) );
+ $this->assertSame( '0.00000000', $this->view( $id )->get_billing_total() );
+ }
+
+ public function test_the_currency_cannot_be_cleared_while_a_total_is_non_zero(): void {
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'currency' => 'USD',
+ 'billing_total' => '10',
+ )
+ )->get_id();
+
+ try {
+ Contracts::update( $id, array( 'currency' => null ) );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertSame( 'USD', $this->view( $id )->get_currency() );
+ }
+
+ // Clearing the totals in the same call is allowed.
+ $this->assertInstanceOf(
+ ContractView::class,
+ Contracts::update(
+ $id,
+ array(
+ 'currency' => null,
+ 'billing_total' => null,
+ )
+ )
+ );
+ $this->assertNull( $this->view( $id )->get_currency() );
+ }
+
+ public function test_progressive_update_builds_the_contract(): void {
+ $customer = self::factory()->user->create();
+ $this->assertIsInt( $customer );
+
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->assertInstanceOf( ContractView::class, Contracts::update( $id, array( 'customer_id' => $customer ) ) );
+ $this->assertInstanceOf(
+ ContractView::class,
+ Contracts::update(
+ $id,
+ array(
+ 'currency' => 'USD',
+ 'billing_total' => '19.99',
+ )
+ )
+ );
+ $updated = Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) );
+ $this->assertInstanceOf( ContractView::class, $updated );
+ $this->assertEquals( $this->view( $id ), $updated, 'The returned view matches a fresh read.' );
+
+ $view = $this->view( $id );
+ $this->assertSame( $customer, $view->get_customer_id() );
+ $this->assertSame( 'USD', $view->get_currency() );
+ $this->assertSame( '19.99000000', $view->get_billing_total() );
+ $this->assertSame( ContractStatus::ACTIVE, $view->get_status() );
+ $this->assertSame( self::EXTENSION_SLUG, $view->get_extension_slug() );
+ }
+
+ public function test_update_keeps_unmentioned_payment_fields(): void {
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'payment_method' => 'dummy',
+ 'payment_method_title' => 'Dummy',
+ 'payment_token_id' => 5,
+ )
+ )->get_id();
+
+ Contracts::update( $id, array( 'payment_token_id' => 6 ) );
+
+ $view = $this->view( $id );
+ $this->assertSame( 'dummy', $view->get_payment_method() );
+ $this->assertSame( 'Dummy', $view->get_payment_method_title() );
+ $this->assertSame( 6, $view->get_payment_token_id() );
+ }
+
+ public function test_update_with_no_fields_returns_the_view(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $updated = Contracts::update( $id, array() );
+
+ $this->assertInstanceOf( ContractView::class, $updated );
+ $this->assertSame( $id, $updated->get_id() );
+ }
+
+ public function test_update_returns_null_when_the_contract_is_deleted_before_the_write(): void {
+ global $wpdb;
+
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ // A concurrent delete lands after the facade read the contract and before its write.
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ $injected = false;
+ $race = static function ( string $query ) use ( &$injected, $table, $id, $wpdb ): string {
+ if ( ! $injected && 0 === strpos( $query, "UPDATE `{$table}`" ) ) {
+ $injected = true;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( $table, array( 'id' => $id ) );
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $race );
+
+ try {
+ $updated = Contracts::update( $id, array( 'payment_method_title' => 'X' ) );
+ } finally {
+ remove_filter( 'query', $race );
+ }
+
+ $this->assertTrue( $injected );
+ $this->assertNull( $updated );
+ }
+
+ public function test_update_does_not_revert_a_concurrent_write_to_other_fields(): void {
+ global $wpdb;
+
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => ContractStatus::ACTIVE,
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ )
+ )->get_id();
+
+ // A concurrent writer (e.g. a cancel compare-and-set) lands after the facade
+ // read the contract and before its own write.
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+ $injected = false;
+ $race = static function ( string $query ) use ( &$injected, $table, $id, $wpdb ): string {
+ if ( ! $injected && 0 === strpos( $query, "UPDATE `{$table}`" ) ) {
+ $injected = true;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update(
+ $table,
+ array(
+ 'status' => ContractStatus::CANCELLED,
+ 'next_payment_gmt' => null,
+ ),
+ array( 'id' => $id )
+ );
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $race );
+
+ try {
+ $updated = Contracts::update( $id, array( 'payment_method_title' => 'X' ) );
+ } finally {
+ remove_filter( 'query', $race );
+ }
+
+ $this->assertTrue( $injected );
+ $this->assertInstanceOf( ContractView::class, $updated );
+ $this->assertSame( 'X', $updated->get_payment_method_title() );
+ $this->assertSame( ContractStatus::ACTIVE, $updated->get_status(), 'The returned view keeps the pre-write read of other columns.' );
+ $view = $this->view( $id );
+ $this->assertSame( 'X', $view->get_payment_method_title() );
+ $this->assertSame( ContractStatus::CANCELLED, $view->get_status() );
+ $this->assertNull( $view->get_next_payment_gmt() );
+ }
+
+ public function test_items_and_addresses_are_replaced_as_a_whole(): void {
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'items' => array( array( 'item_name' => 'Coffee' ), array( 'item_name' => 'Tea' ) ),
+ 'addresses' => array(
+ 'billing' => array( 'city' => 'Lisbon' ),
+ 'shipping' => array( 'city' => 'Porto' ),
+ ),
+ )
+ )->get_id();
+
+ $updated = Contracts::update(
+ $id,
+ array(
+ 'items' => array( array( 'item_name' => 'Cocoa' ) ),
+ 'addresses' => array( 'billing' => array( 'city' => 'Faro' ) ),
+ )
+ );
+ $this->assertInstanceOf( ContractView::class, $updated );
+
+ foreach ( array( $updated, $this->view( $id ) ) as $view ) {
+ $items = $view->get_items();
+ $this->assertIsArray( $items );
+ $this->assertSame( array( 'Cocoa' ), array_column( $items, 'item_name' ) );
+ $addresses = $view->get_addresses();
+ $this->assertIsArray( $addresses );
+ $this->assertSame( array( 'billing' ), array_keys( $addresses ) );
+ $this->assertSame( 'Faro', $addresses['billing']['city'] ?? null );
+ }
+ }
+
+ /**
+ * Run a contract write while every insert into a child table fails, asserting it throws.
+ *
+ * @param string $child_table Child table constant of SchemaInstaller.
+ * @param callable $write The facade write; must throw rather than return.
+ */
+ private function assert_write_throws_when_child_inserts_fail( string $child_table, callable $write ): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( $child_table );
+ $break = static function ( string $query ) use ( $table ): string {
+ return 0 === strpos( $query, "INSERT INTO `{$table}`" ) ? 'SELECT broken syntax (' : $query;
+ };
+ add_filter( 'query', $break );
+ $suppressed = $wpdb->suppress_errors( true );
+
+ try {
+ $write();
+ $this->fail( 'Expected the write to throw instead of returning a view.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'Failed to insert', $e->getMessage() );
+ } finally {
+ $wpdb->suppress_errors( $suppressed );
+ remove_filter( 'query', $break );
+ }
+ }
+
+ public function test_create_throws_when_an_item_insert_fails(): void {
+ $this->assert_write_throws_when_child_inserts_fail(
+ SchemaInstaller::TABLE_CONTRACT_ITEMS,
+ static function () {
+ return Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'items' => array( array( 'item_name' => 'Coffee' ) ),
+ )
+ );
+ }
+ );
+ }
+
+ public function test_update_throws_when_an_item_insert_fails(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->assert_write_throws_when_child_inserts_fail(
+ SchemaInstaller::TABLE_CONTRACT_ITEMS,
+ static function () use ( $id ) {
+ return Contracts::update( $id, array( 'items' => array( array( 'item_name' => 'Cocoa' ) ) ) );
+ }
+ );
+ }
+
+ public function test_update_throws_when_an_address_insert_fails(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->assert_write_throws_when_child_inserts_fail(
+ SchemaInstaller::TABLE_CONTRACT_ADDRESSES,
+ static function () use ( $id ) {
+ return Contracts::update( $id, array( 'addresses' => array( 'billing' => array( 'city' => 'Faro' ) ) ) );
+ }
+ );
+ }
+
+ public function test_null_clears_nullable_fields(): void {
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'selling_plan_id' => 3,
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ )
+ )->get_id();
+
+ Contracts::update(
+ $id,
+ array(
+ 'selling_plan_id' => null,
+ 'next_payment_gmt' => null,
+ )
+ );
+
+ $view = $this->view( $id );
+ $this->assertNull( $view->get_selling_plan_id() );
+ $this->assertNull( $view->get_next_payment_gmt() );
+ }
+
+ public function test_update_on_an_unknown_contract_returns_null(): void {
+ $this->assertNull( Contracts::update( 999999, array( 'status' => ContractStatus::ACTIVE ) ) );
+ }
+
+ public function test_an_unknown_update_key_is_ignored_with_a_notice(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ $this->setExpectedIncorrectUsage( Contracts::class . '::update' );
+ $this->setExpectedIncorrectUsage( Contracts::class );
+
+ $this->assertInstanceOf(
+ ContractView::class,
+ Contracts::update(
+ $id,
+ array(
+ 'nonsense' => 1,
+ 'payment_method' => 'dummy',
+ 'items' => array(
+ array(
+ 'item_name' => 'Coffee',
+ 'price' => '1',
+ ),
+ ),
+ )
+ )
+ );
+
+ $this->assertSame( 'dummy', $this->view( $id )->get_payment_method(), 'The known key beside the unknown one is written.' );
+ $items = $this->entity( $id )->get_items();
+ $this->assertSame( 'Coffee', $items[0]['item_name'] );
+ $this->assertArrayNotHasKey( 'price', $items[0] );
+ }
+
+ public function test_extension_slug_is_not_an_update_key(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ $this->setExpectedIncorrectUsage( Contracts::class . '::update' );
+
+ Contracts::update(
+ $id,
+ array(
+ 'extension_slug' => 'other',
+ 'customer_id' => 7,
+ )
+ );
+
+ $view = $this->view( $id );
+ $this->assertSame( self::EXTENSION_SLUG, $view->get_extension_slug(), 'The extension slug is not rewritten.' );
+ $this->assertSame( 7, $view->get_customer_id() );
+ }
+
+ public function test_snapshot_keys_are_ignored_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( Contracts::class . '::create' );
+ $messages = array();
+ add_action(
+ 'doing_it_wrong_run',
+ static function ( $function_name, $message ) use ( &$messages ): void {
+ $messages[] = $message;
+ },
+ 10,
+ 2
+ );
+
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'plan_snapshot' => array( 'selling_plan_id' => 3 ),
+ 'items_snapshot' => array( array( 'item_name' => 'Coffee' ) ),
+ )
+ )->get_id();
+
+ $this->assertSame(
+ array( 'Unknown key "plan_snapshot" ignored.', 'Unknown key "items_snapshot" ignored.' ),
+ $messages
+ );
+ $this->assertNull( $this->entity( $id )->get_plan_snapshot_id() );
+ $this->assertNull( $this->entity( $id )->get_items_snapshot_id() );
+ }
+
+ public function test_a_registered_extension_status_is_accepted(): void {
+ StatusRegistry::register( StatusRegistry::KIND_CONTRACT, 'paused-by-merchant' );
+
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'status' => 'paused-by-merchant',
+ )
+ )->get_id();
+
+ $this->assertSame( 'paused-by-merchant', $this->view( $id )->get_status() );
+ }
+
+ /**
+ * Create a contract carrying a currency, ready for cycles.
+ */
+ private function contract_with_currency(): int {
+ return Contracts::create(
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'currency' => 'USD',
+ )
+ )->get_id();
+ }
+
+ /**
+ * Fetch a stored cycle by id.
+ *
+ * @param int $contract_id Contract id.
+ * @param int $cycle_id Cycle id.
+ */
+ private function cycle( int $contract_id, int $cycle_id ): Cycle {
+ foreach ( ( new ContractRepository() )->find_cycle_history( $contract_id ) as $cycle ) {
+ if ( $cycle->get_id() === $cycle_id ) {
+ return $cycle;
+ }
+ }
+
+ $this->fail( "Cycle {$cycle_id} not found." );
+ }
+
+ /**
+ * Minimal valid cycle args.
+ *
+ * @param array<string, mixed> $overrides Extra or replacement keys.
+ * @return array<string, mixed>
+ */
+ private function cycle_args( array $overrides = array() ): array {
+ return array_merge(
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ 'currency' => 'USD',
+ ),
+ $overrides
+ );
+ }
+
+ public function test_the_first_cycle_takes_the_chain_defaults(): void {
+ $id = $this->contract_with_currency();
+
+ $cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) )->get_id();
+ $cycle = $this->cycle( $id, $cycle_id );
+
+ $this->assertSame( Cycle::KIND_BILLING, $cycle->get_kind() );
+ $this->assertSame( 1, $cycle->get_sequence_no() );
+ $this->assertNull( $cycle->get_count(), 'The engine never assigns a count.' );
+ $this->assertSame( 'USD', $cycle->get_currency() );
+ $this->assertSame( '0.00000000', $cycle->get_expected_total() );
+ $this->assertSame( 77, $cycle->get_order_id() );
+ $this->assertNull( $cycle->get_extension_slug(), 'The contract is not read, so its owner is not copied.' );
+ $this->assertNull( $cycle->get_plan_snapshot_id() );
+ $this->assertNull( $cycle->get_items_snapshot_id() );
+ }
+
+ public function test_the_next_cycle_defaults_to_the_next_position(): void {
+ $id = $this->contract_with_currency();
+ Contracts::add_cycle( $id, $this->cycle_args() );
+
+ $cycle_id = Contracts::add_cycle(
+ $id,
+ $this->cycle_args(
+ array(
+ 'status' => CycleStatus::PENDING,
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'expected_total' => '19.99',
+ )
+ )
+ )->get_id();
+ $cycle = $this->cycle( $id, $cycle_id );
+
+ $this->assertSame( 2, $cycle->get_sequence_no() );
+ $this->assertNull( $cycle->get_count() );
+ $this->assertSame( '19.99000000', $cycle->get_expected_total() );
+ }
+
+ public function test_a_non_counting_cycle_takes_a_null_count(): void {
+ $id = $this->contract_with_currency();
+
+ $cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => null ) ) )->get_id();
+
+ $this->assertNull( $this->cycle( $id, $cycle_id )->get_count() );
+ }
+
+ public function test_a_taken_position_is_refused(): void {
+ $id = $this->contract_with_currency();
+ Contracts::add_cycle( $id, $this->cycle_args() );
+
+ $this->expectException( DomainException::class );
+
+ Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => 1 ) ) );
+ }
+
+ public function test_a_null_sequence_no_takes_the_next_position(): void {
+ $id = $this->contract_with_currency();
+ Contracts::add_cycle( $id, $this->cycle_args() );
+
+ $view = Contracts::add_cycle( $id, $this->cycle_args( array( 'sequence_no' => null ) ) );
+
+ $this->assertSame( 2, $view->get_sequence_no() );
+ }
+
+ public function test_a_taken_count_is_refused(): void {
+ $id = $this->contract_with_currency();
+ Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
+
+ $this->expectException( DomainException::class );
+
+ Contracts::add_cycle( $id, $this->cycle_args( array( 'count' => 1 ) ) );
+ }
+
+ public function test_an_explicit_position_is_kept(): void {
+ $id = $this->contract_with_currency();
+
+ $cycle_id = Contracts::add_cycle(
+ $id,
+ $this->cycle_args(
+ array(
+ 'sequence_no' => 5,
+ 'count' => 3,
+ )
+ )
+ )->get_id();
+ $cycle = $this->cycle( $id, $cycle_id );
+
+ $this->assertSame( 5, $cycle->get_sequence_no() );
+ $this->assertSame( 3, $cycle->get_count() );
+ }
+
+ public function test_another_kind_starts_its_own_chain(): void {
+ $id = $this->contract_with_currency();
+ Contracts::add_cycle( $id, $this->cycle_args() );
+ Contracts::add_cycle( $id, $this->cycle_args() );
+
+ $view = Contracts::add_cycle( $id, $this->cycle_args( array( 'kind' => 'shipping' ) ) );
+
+ $this->assertSame( 'shipping', $view->get_kind() );
+ $this->assertSame( 1, $view->get_sequence_no() );
+ }
+
+ /**
+ * @dataProvider provide_invalid_cycle_args
+ *
+ * @param array<string, mixed> $args Cycle args.
+ */
+ public function test_invalid_cycle_args_are_rejected( array $args ): void {
+ $id = $this->contract_with_currency();
+
+ try {
+ Contracts::add_cycle( $id, $args );
+ $this->fail( 'Expected an InvalidArgumentException.' );
+ } catch ( InvalidArgumentException $e ) {
+ $this->assertSame( array(), Contracts::get_cycles( $id ), 'No cycle is written.' );
+ }
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_cycle_args(): array {
+ return array(
+ 'unregistered status' => array(
+ array(
+ 'status' => 'nonsense',
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ ),
+ ),
+ 'missing starts_at' => array(
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ ),
+ ),
+ 'missing ends_at' => array(
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ ),
+ ),
+ 'zero sequence number' => array(
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ 'sequence_no' => 0,
+ ),
+ ),
+ 'non-string status' => array(
+ array(
+ 'status' => array( 'billed' ),
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ 'currency' => 'USD',
+ ),
+ ),
+ 'empty kind' => array(
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'kind' => '',
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ ),
+ ),
+ );
+ }
+
+ public function test_add_cycle_defaults_the_status_to_pending(): void {
+ $id = $this->contract_with_currency();
+
+ $cycle = Contracts::add_cycle(
+ $id,
+ array(
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ 'currency' => 'USD',
+ )
+ );
+
+ $this->assertSame( CycleStatus::PENDING, $cycle->get_status() );
+ }
+
+ public function test_an_unknown_cycle_key_is_ignored_with_a_notice(): void {
+ $id = $this->contract_with_currency();
+ $this->setExpectedIncorrectUsage( Contracts::class . '::add_cycle' );
+
+ $cycle_id = Contracts::add_cycle(
+ $id,
+ $this->cycle_args(
+ array(
+ 'reason' => 'x',
+ 'order_id' => 77,
+ )
+ )
+ )->get_id();
+
+ $this->assertSame( 77, $this->cycle( $id, $cycle_id )->get_order_id(), 'The known key beside the unknown one is written.' );
+ }
+
+ public function test_a_cycle_needs_a_currency(): void {
+ $id = $this->contract_with_currency();
+
+ $this->expectException( InvalidArgumentException::class );
+
+ Contracts::add_cycle( $id, $this->cycle_args( array( 'currency' => null ) ) );
+ }
+
+ public function test_the_cycle_currency_is_the_given_one(): void {
+ $id = $this->contract_with_currency();
+
+ $cycle_id = Contracts::add_cycle( $id, $this->cycle_args( array( 'currency' => 'EUR' ) ) )->get_id();
+
+ $this->assertSame( 'EUR', $this->cycle( $id, $cycle_id )->get_currency() );
+ }
+
+ public function test_get_cycles_returns_the_appended_cycle_as_a_view(): void {
+ $id = $this->contract_with_currency();
+ $created = Contracts::add_cycle( $id, $this->cycle_args( array( 'order_id' => 77 ) ) );
+
+ $history = Contracts::get_cycles( $id );
+
+ $this->assertCount( 1, $history );
+ $this->assertEquals( $history[0], $created, 'The returned view matches a fresh read.' );
+ $cycle_id = $created->get_id();
+ $this->assertGreaterThan( 0, $cycle_id );
+ $this->assertInstanceOf( CycleView::class, $history[0] );
+ $this->assertSame( $cycle_id, $history[0]->get_id() );
+ $this->assertSame( CycleStatus::BILLED, $history[0]->get_status() );
+ $this->assertSame( 77, $history[0]->get_order_id() );
+ }
+
+ public function test_meta_keeps_several_values_under_one_key(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->assertIsInt( Contracts::add_meta( $id, 'note', 'one' ) );
+ $this->assertIsInt( Contracts::add_meta( $id, 'note', array( 'two' ) ) );
+
+ $this->assertSame( array( 'one', array( 'two' ) ), Contracts::get_meta( $id, 'note' ) );
+ $this->assertSame( 'one', Contracts::get_meta( $id, 'note', true ) );
+ $this->assertSame( array( 'note' => array( 'one', array( 'two' ) ) ), Contracts::get_meta( $id ) );
+ }
+
+ public function test_a_unique_meta_add_refuses_an_existing_key(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ Contracts::add_meta( $id, 'note', 'one' );
+
+ $this->assertNull( Contracts::add_meta( $id, 'note', 'two', true ) );
+ $this->assertSame( array( 'one' ), Contracts::get_meta( $id, 'note' ) );
+ }
+
+ public function test_update_meta_with_a_previous_value_and_delete_one_value(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ Contracts::add_meta( $id, 'note', 'one' );
+ Contracts::add_meta( $id, 'note', 'two' );
+
+ $this->assertTrue( Contracts::update_meta( $id, 'note', 'three', 'two' ) );
+ $this->assertSame( array( 'one', 'three' ), Contracts::get_meta( $id, 'note' ) );
+
+ $this->assertTrue( Contracts::delete_meta( $id, 'note', 'one' ) );
+ $this->assertSame( array( 'three' ), Contracts::get_meta( $id, 'note' ) );
+ }
+
+ public function test_only_null_matches_any_meta_value(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ Contracts::add_meta( $id, 'note', 'one' );
+ Contracts::add_meta( $id, 'note', '' );
+
+ $this->assertTrue( Contracts::update_meta( $id, 'note', 'blank', '' ) );
+ $this->assertSame( array( 'one', 'blank' ), Contracts::get_meta( $id, 'note' ), 'An empty previous value matches literally.' );
+
+ $this->assertFalse( Contracts::delete_meta( $id, 'note', '' ) );
+ $this->assertSame( array( 'one', 'blank' ), Contracts::get_meta( $id, 'note' ), 'An empty value deletes only empty values.' );
+
+ $this->assertTrue( Contracts::delete_meta( $id, 'note' ) );
+ $this->assertSame( array(), Contracts::get_meta( $id, 'note' ) );
+ }
+
+ public function test_meta_reads_for_an_unknown_contract_are_empty(): void {
+ $this->assertFalse( Contracts::delete_meta( 999999, 'note' ) );
+ $this->assertSame( '', Contracts::get_meta( 999999, 'note', true ) );
+ $this->assertSame( array(), Contracts::get_meta( 999999, 'note' ) );
+ }
+
+ public function test_deleting_a_contract_removes_its_meta(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ Contracts::add_meta( $id, 'note', 'one' );
+
+ $this->assertTrue( ( new ContractRepository() )->delete( $id ) );
+
+ $this->assertSame( array(), Contracts::get_meta( $id, 'note' ) );
+ }
+
+ public function test_an_empty_meta_key_is_rejected(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+
+ $this->expectException( InvalidArgumentException::class );
+
+ Contracts::add_meta( $id, '', 'one' );
+ }
+
+ public function test_meta_survives_a_contract_update(): void {
+ $id = Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_id();
+ Contracts::add_meta( $id, 'note', 'kept' );
+
+ Contracts::update(
+ $id,
+ array(
+ 'status' => ContractStatus::ACTIVE,
+ 'items' => array( array( 'item_name' => 'Coffee' ) ),
+ )
+ );
+
+ $this->assertSame( array( 'kept' ), Contracts::get_meta( $id, 'note' ) );
+ }
+
+ /**
+ * Sign up a contract through the contracts facade (cycle 1 billed). The monthly plan's
+ * cadence is frozen onto the contract's plan snapshot at signup.
+ *
+ * @param int $customer_id Owning customer id; 0 gives the order a new customer.
+ * @return Contract The persisted contract with cycle 1 billed.
+ */
+ private function sign_up_contract( int $customer_id = 0 ): Contract {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Monthly',
+ 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+ 'category' => Plan::DEFAULT_CATEGORY,
+ 'extension_slug' => 'engine-tests',
+ )
+ );
+ ( new PlanRepository() )->insert( $plan );
+
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( 'dummy' );
+ $order->set_total( '19.99' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ if ( $customer_id > 0 ) {
+ $order->set_customer_id( $customer_id );
+ }
+ $order->save();
+
+ $contract = ( new ContractRepository() )->find( $this->sign_up_from_order( $order, $plan ) );
+ $this->assertInstanceOf( Contract::class, $contract );
+
+ return $contract;
+ }
+
+ /**
+ * Seed a bare contract at a status and billing total for the list-query tests,
+ * returning its id.
+ *
+ * @param string $status Contract status.
+ * @param string $billing_total Billing total (decimal string).
+ */
+ private function seed_list_contract( string $status = ContractStatus::ACTIVE, string $billing_total = '19.99' ): int {
+ $contract = Contract::create(
+ array(
+ 'extension_slug' => 'engine-tests',
+ 'customer_id' => 42,
+ 'status' => $status,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 1,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2099-02-01 00:00:00',
+ 'billing_total' => $billing_total,
+ )
+ );
+
+ return ( new ContractRepository() )->insert( $contract );
+ }
+
+ /**
+ * Seed a contract for a customer at a status, returning its id.
+ *
+ * @param int $customer_id Owning customer.
+ * @param string $status Contract status.
+ */
+ private function seed_for_customer( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
+ $contract = Contract::create(
+ array(
+ 'extension_slug' => 'engine-tests',
+ 'customer_id' => $customer_id,
+ 'status' => $status,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 1,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2099-02-01 00:00:00',
+ 'billing_total' => '19.99',
+ )
+ );
+
+ return ( new ContractRepository() )->insert( $contract );
+ }
+
+ /**
+ * @testdox get returns the contract, and null for an unknown id.
+ */
+ public function test_get_round_trips_a_contract(): void {
+ $contract = $this->sign_up_contract();
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $loaded = Contracts::get( $contract_id );
+ $this->assertInstanceOf( ContractView::class, $loaded );
+ $this->assertSame( $contract_id, $loaded->get_id() );
+ $this->assertIsArray( $loaded->get_items(), 'A single read loads children.' );
+ $this->assertIsArray( $loaded->get_addresses(), 'A single read loads children.' );
+
+ $this->assertNull( Contracts::get( 999999 ) );
+ }
+
+ /**
+ * @testdox find_by_origin_order returns views of the contracts created from an order.
+ */
+ public function test_find_by_origin_order_returns_views(): void {
+ $contract = $this->sign_up_contract();
+ $order_id = $contract->get_origin_order_id();
+ $this->assertNotNull( $order_id );
+
+ $found = Contracts::find_by_origin_order( $order_id );
+
+ $this->assertCount( 1, $found );
+ $this->assertInstanceOf( ContractView::class, $found[0] );
+ $this->assertSame( $contract->get_id(), $found[0]->get_id() );
+ $this->assertSame( $order_id, $found[0]->get_origin_order_id() );
+ $this->assertNull( $found[0]->get_items() );
+ $this->assertSame( array(), Contracts::find_by_origin_order( 999999 ) );
+ }
+
+ /**
+ * @testdox list_for_customer does not load children.
+ */
+ public function test_list_for_customer_does_not_load_children(): void {
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ $this->assertIsInt( $customer_id );
+
+ $this->sign_up_contract( $customer_id );
+
+ $contracts = Contracts::list_for_customer( $customer_id );
+ $this->assertCount( 1, $contracts );
+ $this->assertNull( $contracts[0]->get_items(), 'List reads do not load children.' );
+ }
+
+ /**
+ * @testdox list does not load children.
+ */
+ public function test_list_does_not_load_children(): void {
+ $this->sign_up_contract();
+
+ $contracts = Contracts::list( array( 'limit' => 1 ) );
+ $this->assertCount( 1, $contracts );
+ $this->assertNull( $contracts[0]->get_items(), 'List reads do not load children.' );
+ $this->assertNull( $contracts[0]->get_addresses(), 'List reads do not load children.' );
+ }
+
+ /**
+ * @testdox list returns recent contracts newest first.
+ */
+ public function test_list_returns_recent_contracts(): void {
+ $first = $this->sign_up_contract();
+ $second = $this->sign_up_contract();
+
+ $contracts = Contracts::list();
+ $ids = array_map( static fn ( ContractView $c ) => $c->get_id(), $contracts );
+
+ // Newest first, and both signups are present.
+ $this->assertSame( array( $second->get_id(), $first->get_id() ), array_slice( $ids, 0, 2 ) );
+ $this->assertInstanceOf( ContractView::class, $contracts[0] );
+ }
+
+ /**
+ * @testdox list passes status / sort / search / paging args through to the query.
+ */
+ public function test_list_passes_query_args_through(): void {
+ $active_low = $this->seed_list_contract( ContractStatus::ACTIVE, '10.00' );
+ $active_high = $this->seed_list_contract( ContractStatus::ACTIVE, '20.00' );
+ $this->seed_list_contract( ContractStatus::CANCELLED, '30.00' );
+
+ // Status filter + sort compose through the facade.
+ $ids = array_map(
+ static fn ( ContractView $c ) => (int) $c->get_id(),
+ Contracts::list(
+ array(
+ 'status' => ContractStatus::ACTIVE,
+ 'orderby' => 'total',
+ 'order' => 'ASC',
+ )
+ )
+ );
+ $this->assertSame( array( $active_low, $active_high ), $ids );
+
+ // Paging windows the same filtered set.
+ $page = Contracts::list(
+ array(
+ 'status' => ContractStatus::ACTIVE,
+ 'limit' => 1,
+ 'offset' => 1,
+ )
+ );
+ $this->assertCount( 1, $page );
+ }
+
+ /**
+ * @testdox count_by_status returns a full status map and count honours the same filter.
+ */
+ public function test_count_by_status_and_count(): void {
+ $this->seed_list_contract( ContractStatus::ACTIVE );
+ $this->seed_list_contract( ContractStatus::ACTIVE );
+ $this->seed_list_contract( ContractStatus::ON_HOLD );
+
+ $by_status = Contracts::count_by_status();
+ $this->assertSame( ContractStatus::get_all(), array_keys( $by_status ) );
+ $this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
+ $this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
+ $this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
+
+ // The grand total and a status-filtered total agree with the map.
+ $this->assertSame( 3, Contracts::count() );
+ $this->assertSame( 2, Contracts::count( array( 'status' => ContractStatus::ACTIVE ) ) );
+ }
+
+ /**
+ * @testdox get_cycles returns the billing cycles newest first.
+ */
+ public function test_get_cycles_returns_cycles(): void {
+ $contract = $this->sign_up_contract();
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $history = Contracts::get_cycles( $contract_id );
+ $this->assertCount( 1, $history );
+ $this->assertInstanceOf( CycleView::class, $history[0] );
+ $this->assertSame( 1, $history[0]->get_count() );
+ $this->assertSame( CycleStatus::BILLED, $history[0]->get_status() );
+ }
+
+ /**
+ * @testdox list_for_customer returns only the requested customer's contracts.
+ */
+ public function test_list_for_customer_is_owner_scoped(): void {
+ $mine_a = $this->seed_for_customer( 41 );
+ $mine_b = $this->seed_for_customer( 41 );
+ $theirs = $this->seed_for_customer( 42 );
+
+ $ids = array_map(
+ static fn ( ContractView $c ) => (int) $c->get_id(),
+ Contracts::list_for_customer( 41 )
+ );
+
+ $this->assertContains( $mine_a, $ids );
+ $this->assertContains( $mine_b, $ids );
+ $this->assertNotContains( $theirs, $ids );
+ $this->assertCount( 2, $ids );
+ }
+
+ /**
+ * @testdox list_for_customer filters by status before paging.
+ */
+ public function test_list_for_customer_filters_by_status_before_paging(): void {
+ $active_old = $this->seed_for_customer( 44 );
+ $this->seed_for_customer( 44, ContractStatus::DRAFT );
+ $on_hold = $this->seed_for_customer( 44, ContractStatus::ON_HOLD );
+ $this->seed_for_customer( 44, ContractStatus::DRAFT );
+
+ $ids = static fn ( array $views ): array => array_map( static fn ( ContractView $c ): int => (int) $c->get_id(), $views );
+ $args = array( 'status' => array( ContractStatus::ACTIVE, ContractStatus::ON_HOLD ) );
+
+ $this->assertSame( array( $on_hold ), $ids( Contracts::list_for_customer( 44, 1, 0, $args ) ) );
+ $this->assertSame( array( $active_old ), $ids( Contracts::list_for_customer( 44, 1, 1, $args ) ) );
+ $this->assertSame( array( $on_hold ), $ids( Contracts::list_for_customer( 44, 20, 0, array( 'status' => ContractStatus::ON_HOLD ) ) ), 'A single status is accepted.' );
+ $this->assertCount( 4, Contracts::list_for_customer( 44, 20, 0, array( 'status' => array( 'nonsense' ) ) ), 'An unregistered status is dropped, leaving no filter.' );
+ }
+
+ /**
+ * @testdox get_for_customer returns the contract when the customer owns it.
+ */
+ public function test_get_for_customer_returns_the_owned_contract(): void {
+ $id = $this->seed_for_customer( 43 );
+
+ $contract = Contracts::get_for_customer( $id, 43 );
+
+ $this->assertInstanceOf( ContractView::class, $contract );
+ $this->assertSame( $id, $contract->get_id() );
+ }
+
+ /**
+ * @testdox get_for_customer is null for both a foreign owner and an unknown id (asymmetric).
+ */
+ public function test_get_for_customer_is_null_for_foreign_and_unknown(): void {
+ $id = $this->seed_for_customer( 44 );
+
+ $this->assertNull( Contracts::get_for_customer( $id, 45 ), 'foreign-owned reads as not found' );
+ $this->assertNull( Contracts::get_for_customer( 987654, 44 ), 'unknown id reads as not found' );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
index 20b863c8e76..89c629be8f6 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/ContractsControllerTest.php
@@ -86,6 +86,7 @@ class ContractsControllerTest extends EngineIntegrationTestCase {
private function seed( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => $customer_id,
'status' => $status,
'currency' => 'USD',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
index bd78adfb26f..112b9b1d09a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/SubscriptionsTest.php
@@ -1,6 +1,6 @@
<?php
/**
- * Integration tests for the public Subscriptions facade.
+ * Integration tests for the interim Subscriptions lifecycle and renewal facade.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -11,17 +11,16 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
use EngineIntegrationTestCase;
use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
@@ -48,10 +47,10 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
}
/**
- * Sign up a contract via the checkout factory (cycle 1 billed). The monthly plan's
+ * Sign up a contract through the contracts facade (cycle 1 billed). The monthly plan's
* cadence is frozen onto the contract's plan snapshot at signup.
*
- * @param int $customer_id Owning customer id; 0 leaves the order customer unset.
+ * @param int $customer_id Owning customer id; 0 gives the order a new customer.
* @return Contract The persisted contract with cycle 1 billed.
*/
private function sign_up_contract( int $customer_id = 0 ): Contract {
@@ -75,77 +74,44 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
}
$order->save();
- return ( new ContractFactory() )->create_from_order( $order, $plan );
- }
-
- /**
- * Repoint the live plan a contract was created under to a different cadence, to prove
- * a read sources cadence from the frozen snapshot rather than the live plan.
- *
- * @param Contract $contract The contract whose live plan to mutate.
- * @param BillingPolicy $policy The new live cadence.
- */
- private function repoint_live_plan( Contract $contract, BillingPolicy $policy ): void {
- $plans = new PlanRepository();
- $plan = $plans->find( $contract->get_selling_plan_id() );
- $this->assertInstanceOf( Plan::class, $plan );
+ $contract = ( new ContractRepository() )->find( $this->sign_up_from_order( $order, $plan ) );
+ $this->assertInstanceOf( Contract::class, $contract );
- $plan->set_billing_policy( $policy );
- $plans->update( $plan );
+ return $contract;
}
/**
- * @testdox get returns the contract, and null for an unknown id.
+ * @testdox get_related_orders returns the contract's linked orders (the origin order).
*/
- public function test_get_round_trips_a_contract(): void {
+ public function test_get_related_orders_returns_the_linked_orders(): void {
$contract = $this->sign_up_contract();
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- $loaded = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $loaded );
- $this->assertSame( $contract_id, $loaded->get_id() );
+ $orders = Subscriptions::get_related_orders( $contract_id );
- $this->assertNull( Subscriptions::get( 999999 ) );
+ $this->assertCount( 1, $orders );
+ $this->assertInstanceOf( WC_Order::class, $orders[0] );
+ $this->assertSame( $contract->get_origin_order_id(), $orders[0]->get_id() );
}
/**
- * @testdox get hydrates the contract's frozen plan terms, and the snapshot wins over a changed live plan.
+ * @testdox get_related_orders lists an origin order that also carries the contract meta once.
*/
- public function test_get_hydrates_the_plan_snapshot_and_snapshot_wins(): void {
+ public function test_get_related_orders_lists_a_meta_tagged_origin_once(): void {
$contract = $this->sign_up_contract();
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- // Edit the live plan AFTER signup: the frozen snapshot must not move with it.
- $this->repoint_live_plan( $contract, new BillingPolicy( 'year', 2, null, null, null ) );
-
- $loaded = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $loaded );
-
- $snapshot = $loaded->get_plan_snapshot();
- $this->assertInstanceOf( PlanSnapshot::class, $snapshot, 'get() must hydrate the plan snapshot.' );
-
- $policy = $snapshot->get_billing_policy();
- $this->assertInstanceOf( BillingPolicy::class, $policy );
- // Frozen monthly cadence, NOT the live plan's edited yearly cadence.
- $this->assertSame( 'month', $policy->get_period() );
- $this->assertSame( 1, $policy->get_interval() );
- }
-
- /**
- * @testdox get_related_orders returns the contract's linked orders (the origin order).
- */
- public function test_get_related_orders_returns_the_linked_orders(): void {
- $contract = $this->sign_up_contract();
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
+ $origin = wc_get_order( (int) $contract->get_origin_order_id() );
+ $this->assertInstanceOf( WC_Order::class, $origin );
+ $origin->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
+ $origin->save();
$orders = Subscriptions::get_related_orders( $contract_id );
$this->assertCount( 1, $orders );
- $this->assertInstanceOf( WC_Order::class, $orders[0] );
- $this->assertSame( $contract->get_origin_order_id(), $orders[0]->get_id() );
+ $this->assertSame( $origin->get_id(), $orders[0]->get_id() );
}
/**
@@ -198,115 +164,53 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
}
/**
- * @testdox list_for_customer hydrates each row's frozen plan terms.
+ * @testdox get_related_orders bounds the linked-order query to the page and still pages like an unbounded read.
*/
- public function test_list_for_customer_hydrates_the_plan_snapshot(): void {
- $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
- $this->assertIsInt( $customer_id );
-
- $this->sign_up_contract( $customer_id );
-
- $contracts = Subscriptions::list_for_customer( $customer_id );
- $this->assertCount( 1, $contracts );
-
- $snapshot = $contracts[0]->get_plan_snapshot();
- $this->assertInstanceOf( PlanSnapshot::class, $snapshot, 'list_for_customer must hydrate each row\'s plan snapshot.' );
- $policy = $snapshot->get_billing_policy();
- $this->assertInstanceOf( BillingPolicy::class, $policy );
- $this->assertSame( 'month', $policy->get_period() );
- }
-
- /**
- * @testdox list hydrates each row's frozen plan terms, like the customer list.
- */
- public function test_list_hydrates_the_plan_snapshot(): void {
- $this->sign_up_contract();
-
- $contracts = Subscriptions::list( array( 'limit' => 1 ) );
- $this->assertCount( 1, $contracts );
-
- $snapshot = $contracts[0]->get_plan_snapshot();
- $this->assertInstanceOf( PlanSnapshot::class, $snapshot, 'list must hydrate each row\'s plan snapshot.' );
- }
-
- /**
- * @testdox list returns recent contracts newest first.
- */
- public function test_list_returns_recent_contracts(): void {
- $first = $this->sign_up_contract();
- $second = $this->sign_up_contract();
-
- $contracts = Subscriptions::list();
- $ids = array_map( static fn ( Contract $c ) => $c->get_id(), $contracts );
-
- // Newest first, and both signups are present.
- $this->assertSame( array( $second->get_id(), $first->get_id() ), array_slice( $ids, 0, 2 ) );
- $this->assertInstanceOf( Contract::class, $contracts[0] );
- }
-
- /**
- * @testdox list passes status / sort / search / paging args through to the query.
- */
- public function test_list_passes_query_args_through(): void {
- $active_low = $this->seed_list_contract( ContractStatus::ACTIVE, '10.00' );
- $active_high = $this->seed_list_contract( ContractStatus::ACTIVE, '20.00' );
- $this->seed_list_contract( ContractStatus::CANCELLED, '30.00' );
-
- // Status filter + sort compose through the facade.
- $ids = array_map(
- static fn ( Contract $c ) => (int) $c->get_id(),
- Subscriptions::list(
- array(
- 'status' => ContractStatus::ACTIVE,
- 'orderby' => 'total',
- 'order' => 'ASC',
- )
- )
- );
- $this->assertSame( array( $active_low, $active_high ), $ids );
-
- // Paging windows the same filtered set.
- $page = Subscriptions::list(
- array(
- 'status' => ContractStatus::ACTIVE,
- 'limit' => 1,
- 'offset' => 1,
- )
- );
- $this->assertCount( 1, $page );
- }
-
- /**
- * @testdox count_by_status returns a full status map and count honours the same filter.
- */
- public function test_count_by_status_and_count(): void {
- $this->seed_list_contract( ContractStatus::ACTIVE );
- $this->seed_list_contract( ContractStatus::ACTIVE );
- $this->seed_list_contract( ContractStatus::ON_HOLD );
-
- $by_status = Subscriptions::count_by_status();
- $this->assertSame( ContractStatus::get_all(), array_keys( $by_status ) );
- $this->assertSame( 2, $by_status[ ContractStatus::ACTIVE ] );
- $this->assertSame( 1, $by_status[ ContractStatus::ON_HOLD ] );
- $this->assertSame( 0, $by_status[ ContractStatus::CANCELLED ] );
-
- // The grand total and a status-filtered total agree with the map.
- $this->assertSame( 3, Subscriptions::count() );
- $this->assertSame( 2, Subscriptions::count( array( 'status' => ContractStatus::ACTIVE ) ) );
- }
-
- /**
- * @testdox get_history returns the billing cycles newest first.
- */
- public function test_get_history_returns_cycles(): void {
+ public function test_get_related_orders_bounds_the_query_to_the_page(): void {
$contract = $this->sign_up_contract();
$contract_id = $contract->get_id();
$this->assertNotNull( $contract_id );
- $history = Subscriptions::get_history( $contract_id );
- $this->assertCount( 1, $history );
- $this->assertInstanceOf( Cycle::class, $history[0] );
- $this->assertSame( 1, $history[0]->get_count() );
+ // Five renewal orders dated after the origin, newest last created.
+ foreach ( array( 1, 2, 3, 4, 5 ) as $days_ahead ) {
+ $order = wc_create_order();
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $order->set_date_created( gmdate( 'Y-m-d H:i:s', time() + ( $days_ahead * DAY_IN_SECONDS ) ) );
+ $order->update_meta_data( OrderLinkage::META_CONTRACT_ID, (string) $contract_id );
+ $order->update_meta_data( OrderLinkage::META_RELATION_TYPE, OrderLinkage::RELATION_RENEWAL );
+ $order->save();
+ }
+
+ $all_ids = array_map(
+ static function ( WC_Order $order ): int {
+ return $order->get_id();
+ },
+ Subscriptions::get_related_orders( $contract_id )
+ );
+ $this->assertCount( 6, $all_ids );
+
+ $limits = array();
+ $record = static function ( array $args ) use ( &$limits ): array {
+ $limits[] = $args['limit'] ?? null;
+ return $args;
+ };
+ add_filter( 'woocommerce_order_query_args', $record );
+
+ try {
+ foreach ( array( 0, 2, 4 ) as $offset ) {
+ $page_ids = array_map(
+ static function ( WC_Order $order ): int {
+ return $order->get_id();
+ },
+ Subscriptions::get_related_orders( $contract_id, 2, $offset )
+ );
+ $this->assertSame( array_slice( $all_ids, $offset, 2 ), $page_ids, "Page at offset {$offset} matches the unbounded read." );
+ }
+ } finally {
+ remove_filter( 'woocommerce_order_query_args', $record );
+ }
+
+ $this->assertSame( array( 2, 4, 6 ), $limits, 'The linked-order query is bounded to offset + limit.' );
}
/**
@@ -339,114 +243,28 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
$this->assertInstanceOf( WC_Order::class, $renewal_order );
$this->assertTrue( $renewal_order->is_paid() );
- $history = Subscriptions::get_history( $contract_id );
+ $history = Contracts::get_cycles( $contract_id );
$this->assertCount( 2, $history );
// Newest first: cycle 2 is billed, linked to the renewal order.
$cycle_two = $history[0];
$this->assertSame( 2, $cycle_two->get_count() );
- $this->assertTrue( $cycle_two->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
+ $this->assertSame( CycleStatus::BILLED, $cycle_two->get_status() );
$this->assertSame( $renewal_order->get_id(), $cycle_two->get_order_id() );
// The schedule advanced one cadence (cycle 1 ended 2026-02-15 + 1 month).
- $after_renew = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $after_renew );
+ $after_renew = Contracts::get( $contract_id );
+ $this->assertInstanceOf( ContractView::class, $after_renew );
$this->assertSame( '2026-03-15 00:00:00', $after_renew->get_next_payment_gmt() );
// Cancel: the contract goes terminal.
$this->assertTrue( Subscriptions::cancel( $contract_id ) );
- $after_cancel = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $after_cancel );
+ $after_cancel = Contracts::get( $contract_id );
+ $this->assertInstanceOf( ContractView::class, $after_cancel );
$this->assertSame( ContractStatus::CANCELLED, $after_cancel->get_status() );
}
- /**
- * Seed a bare contract at a status and billing total for the list-query tests,
- * returning its id.
- *
- * @param string $status Contract status.
- * @param string $billing_total Billing total (decimal string).
- */
- private function seed_list_contract( string $status = ContractStatus::ACTIVE, string $billing_total = '19.99' ): int {
- $contract = Contract::create(
- array(
- 'customer_id' => 42,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2099-02-01 00:00:00',
- 'billing_total' => $billing_total,
- )
- );
-
- return ( new ContractRepository() )->insert( $contract );
- }
-
- /**
- * Seed a contract for a customer at a status, returning its id.
- *
- * @param int $customer_id Owning customer.
- * @param string $status Contract status.
- */
- private function seed_for_customer( int $customer_id, string $status = ContractStatus::ACTIVE ): int {
- $contract = Contract::create(
- array(
- 'customer_id' => $customer_id,
- 'status' => $status,
- 'currency' => 'USD',
- 'selling_plan_id' => 1,
- 'start_gmt' => '2026-01-01 00:00:00',
- 'next_payment_gmt' => '2099-02-01 00:00:00',
- 'billing_total' => '19.99',
- )
- );
-
- return ( new ContractRepository() )->insert( $contract );
- }
-
- /**
- * @testdox list_for_customer returns only the requested customer's contracts.
- */
- public function test_list_for_customer_is_owner_scoped(): void {
- $mine_a = $this->seed_for_customer( 41 );
- $mine_b = $this->seed_for_customer( 41 );
- $theirs = $this->seed_for_customer( 42 );
-
- $ids = array_map(
- static fn ( Contract $c ) => (int) $c->get_id(),
- Subscriptions::list_for_customer( 41 )
- );
-
- $this->assertContains( $mine_a, $ids );
- $this->assertContains( $mine_b, $ids );
- $this->assertNotContains( $theirs, $ids );
- $this->assertCount( 2, $ids );
- }
-
- /**
- * @testdox get_for_customer returns the contract when the customer owns it.
- */
- public function test_get_for_customer_returns_the_owned_contract(): void {
- $id = $this->seed_for_customer( 43 );
-
- $contract = Subscriptions::get_for_customer( $id, 43 );
-
- $this->assertInstanceOf( Contract::class, $contract );
- $this->assertSame( $id, $contract->get_id() );
- }
-
- /**
- * @testdox get_for_customer is null for both a foreign owner and an unknown id (asymmetric).
- */
- public function test_get_for_customer_is_null_for_foreign_and_unknown(): void {
- $id = $this->seed_for_customer( 44 );
-
- $this->assertNull( Subscriptions::get_for_customer( $id, 45 ), 'foreign-owned reads as not found' );
- $this->assertNull( Subscriptions::get_for_customer( 987654, 44 ), 'unknown id reads as not found' );
- }
-
/**
* @testdox the lifecycle verbs return false for an unknown contract.
*/
@@ -465,20 +283,20 @@ class SubscriptionsTest extends EngineIntegrationTestCase {
$this->assertNotNull( $contract_id );
$this->assertTrue( Subscriptions::hold( $contract_id ) );
- $held = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $held );
+ $held = Contracts::get( $contract_id );
+ $this->assertInstanceOf( ContractView::class, $held );
$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
$this->assertNull( $held->get_next_payment_gmt(), 'Hold disarms the next-due moment.' );
$this->assertTrue( Subscriptions::reactivate( $contract_id ) );
- $active = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $active );
+ $active = Contracts::get( $contract_id );
+ $this->assertInstanceOf( ContractView::class, $active );
$this->assertSame( ContractStatus::ACTIVE, $active->get_status() );
$this->assertNotNull( $active->get_next_payment_gmt(), 'Reactivate re-arms the next-due moment.' );
$this->assertTrue( Subscriptions::cancel_at_period_end( $contract_id ) );
- $pending = Subscriptions::get( $contract_id );
- $this->assertInstanceOf( Contract::class, $pending );
+ $pending = Contracts::get( $contract_id );
+ $this->assertInstanceOf( ContractView::class, $pending );
$this->assertSame( ContractStatus::PENDING_CANCELLATION, $pending->get_status() );
$this->assertNull( $pending->get_next_payment_gmt(), 'Cancel at period end disarms the next-due moment.' );
$this->assertNotNull( $pending->get_end_gmt(), 'The former next-due moment becomes the end date.' );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index 27ddf59396c..ff963ed45d5 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -10,6 +10,10 @@
declare( strict_types=1 );
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
/**
@@ -84,4 +88,95 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
$this->approved_gateways[] = $gateway;
}
+
+ /**
+ * Sign up a contract for a paid order on `$plan` through the contracts facade, the way an
+ * extension maps its checkout: create a draft from explicit order fields and snapshots,
+ * record cycle 1 (billed, linked to the order), then activate. An order without a
+ * customer gets a new one.
+ *
+ * @param WC_Order $order Saved, paid order.
+ * @param Plan $plan Saved selling plan.
+ * @param array<string, mixed> $overrides `Contracts::create()` fields to replace; `status` is the final status.
+ * @return int The contract id.
+ */
+ protected function sign_up_from_order( WC_Order $order, Plan $plan, array $overrides = array() ): int {
+ if ( $order->get_customer_id() <= 0 ) {
+ $customer_id = self::factory()->user->create();
+ $this->assertIsInt( $customer_id );
+ $order->set_customer_id( $customer_id );
+ $order->save();
+ }
+
+ $paid = $order->get_date_paid();
+ $start = null !== $paid
+ ? new DateTimeImmutable( '@' . $paid->getTimestamp() )
+ : new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+
+ $items = array();
+ foreach ( $order->get_items() as $item ) {
+ if ( $item instanceof WC_Order_Item_Product ) {
+ $items[] = array(
+ 'item_name' => $item->get_name(),
+ 'item_type' => 'line_item',
+ 'product_id' => $item->get_product_id(),
+ 'variation_id' => $item->get_variation_id(),
+ 'quantity' => (string) $item->get_quantity(),
+ 'subtotal' => (string) $item->get_subtotal(),
+ 'total' => (string) $item->get_total(),
+ 'taxes' => $item->get_taxes(),
+ );
+ }
+ }
+
+ $tokens = $order->get_payment_tokens();
+ $token_id = array() !== $tokens ? (int) end( $tokens ) : 0;
+
+ $args = array_merge(
+ array(
+ 'extension_slug' => (string) $plan->get_extension_slug(),
+ 'customer_id' => $order->get_customer_id(),
+ 'currency' => $order->get_currency(),
+ 'selling_plan_id' => $plan->get_id(),
+ 'origin_order_id' => $order->get_id(),
+ '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' => $token_id > 0 ? $token_id : null,
+ 'start_gmt' => $start,
+ 'next_payment_gmt' => $plan->get_billing_policy()->compute_first_renewal_from( $start ),
+ 'billing_total' => (string) $order->get_total(),
+ 'discount_total' => (string) $order->get_total_discount(),
+ 'shipping_total' => (string) $order->get_shipping_total(),
+ 'tax_total' => (string) $order->get_total_tax(),
+ 'items' => $items,
+ 'addresses' => array(
+ 'billing' => $order->get_address( 'billing' ),
+ 'shipping' => $order->get_address( 'shipping' ),
+ ),
+ ),
+ $overrides
+ );
+
+ $status = $args['status'] ?? ContractStatus::ACTIVE;
+ $args['status'] = ContractStatus::DRAFT;
+
+ $view = Contracts::create( $args );
+ $id = $view->get_id();
+
+ Contracts::add_cycle(
+ $id,
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'count' => 1,
+ 'order_id' => $order->get_id(),
+ 'starts_at_gmt' => (string) $view->get_start_gmt(),
+ 'ends_at_gmt' => (string) $view->get_next_payment_gmt(),
+ 'expected_total' => $view->get_billing_total(),
+ 'currency' => $view->get_currency(),
+ )
+ );
+ Contracts::update( $id, array( 'status' => $status ) );
+
+ return $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
deleted file mode 100644
index 995b2891c41..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ /dev/null
@@ -1,331 +0,0 @@
-<?php
-/**
- * Integration tests for ContractFactory.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Checkout;
-
-use EngineIntegrationTestCase;
-use WC_Order;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage
- */
-class ContractFactoryTest extends EngineIntegrationTestCase {
-
- /**
- * @param int|null $max_cycles Maximum number of billing cycles, or null for unlimited.
- * @param array{length: int, unit: string}|null $trial Native trial duration, or null for none.
- */
- private function make_plan( ?int $max_cycles = null, ?array $trial = null ): Plan {
- $plan = Plan::create(
- array(
- 'name' => 'Monthly coffee',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, $max_cycles, $trial ),
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'lite',
- )
- );
- ( new PlanRepository() )->insert( $plan );
-
- return $plan;
- }
-
- private function make_order(): WC_Order {
- $order = new WC_Order();
- $order->set_currency( 'USD' );
- $order->set_payment_method( 'woocommerce_payments' );
- $order->set_payment_method_title( 'Credit card' );
- $order->set_total( '19.99' );
- $order->set_address(
- array(
- 'first_name' => 'Ada',
- 'last_name' => 'Lovelace',
- 'country' => 'US',
- 'email' => 'ada@example.test',
- ),
- 'billing'
- );
- $order->save();
-
- return $order;
- }
-
- /**
- * @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();
-
- $contract = ( new ContractFactory() )->create_from_order( $order, $plan );
-
- $this->assertNotNull( $contract->get_id() );
- $this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
- $this->assertSame( 'USD', $contract->get_currency() );
- $this->assertSame( $plan->get_id(), $contract->get_selling_plan_id() );
- $this->assertSame( $order->get_id(), $contract->get_origin_order_id() );
- $this->assertSame( 'lite', $contract->get_extension_slug() );
- $this->assertSame( 'woocommerce_payments', $contract->get_payment_instrument()->get_gateway() );
-
- // Persisted and reloadable.
- $reloaded = ( new ContractRepository() )->find( $contract->get_id() );
- $this->assertInstanceOf( Contract::class, $reloaded );
- $this->assertSame( $contract->get_id(), $reloaded->get_id() );
-
- // Order is tagged with the parent relation.
- $tagged_order = wc_get_order( $order->get_id() );
- $this->assertInstanceOf( WC_Order::class, $tagged_order );
- $this->assertSame( (string) $contract->get_id(), $tagged_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
- $this->assertSame( OrderLinkage::RELATION_PARENT, $tagged_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
- }
-
- /**
- * @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_chain_head( $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( new CycleStatus( 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' );
- $order->save();
-
- $contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
-
- $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' );
- $order->save();
-
- $plan = $this->make_plan(
- null,
- array(
- 'length' => 14,
- 'unit' => 'day',
- )
- );
-
- $contract = ( new ContractFactory() )->create_from_order( $order, $plan );
-
- // First bill is the trial end, not one month out.
- $this->assertSame( '2026-01-29 00:00:00', $contract->get_next_payment_gmt() );
-
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Cycle 1's period end matches that first renewal date.
- $cycle = ( new ContractRepository() )->find_chain_head( $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();
-
- $contract = ( new ContractFactory() )->create_from_order(
- $order,
- $plan,
- array(
- 'billing_total' => '49.00',
- 'next_payment_gmt' => '2026-12-01 00:00:00',
- )
- );
-
- // 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_chain_head( $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 The origin cycle's plan snapshot freezes the pricing payload exactly as stored.
- */
- public function test_plan_snapshot_round_trips_the_pricing_policy(): void {
- $pricing_policy = array(
- 'policies' => array(
- array(
- 'type' => 'bogo',
- 'duration_cycles' => 1,
- ),
- array(
- 'type' => 'tiered',
- 'value' => 10,
- ),
- ),
- 'one_time_fees' => array(),
- 'custom_key' => 'kept',
- );
-
- $plan = Plan::create(
- array(
- 'name' => 'Discounted monthly',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'pricing_policy' => $pricing_policy,
- 'category' => Plan::DEFAULT_CATEGORY,
- 'extension_slug' => 'lite',
- )
- );
- ( new PlanRepository() )->insert( $plan );
-
- $contract = ( new ContractFactory() )->create_from_order( $this->make_order(), $plan );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $cycle );
-
- $snapshot = $repo->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
- $this->assertInstanceOf( PlanSnapshot::class, $snapshot );
-
- // The engine freezes the payload uninterpreted: an unknown type and an extra
- // key survive the DB round-trip, and no value is added to the bogo entry.
- $this->assertSame( $pricing_policy, $snapshot->to_array()['pricing_policy'] );
- $this->assertSame( $pricing_policy, $snapshot->get_pricing_policy() );
- }
-
- /**
- * @testdox The plan snapshot records an explicit null when the plan has no pricing policy.
- */
- public function test_plan_snapshot_is_null_safe_without_a_pricing_policy(): void {
- $contract = ( new ContractFactory() )->create_from_order( $this->make_order(), $this->make_plan() );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- $repo = new ContractRepository();
- $cycle = $repo->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $cycle );
-
- $snapshot = $repo->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
- $this->assertInstanceOf( PlanSnapshot::class, $snapshot );
-
- // Explicit null (not a missing key): the frozen terms deliberately record
- // "no pricing policy at signup", distinguishable from a pre-key snapshot.
- $payload = $snapshot->to_array();
- $this->assertArrayHasKey( 'pricing_policy', $payload );
- $this->assertNull( $payload['pricing_policy'] );
- $this->assertNull( $snapshot->get_pricing_policy() );
- }
-
- /**
- * @testdox An unsaved plan is rejected.
- */
- public function test_unsaved_plan_is_rejected(): void {
- $order = $this->make_order();
- $plan = Plan::create(
- array(
- 'name' => 'Monthly coffee',
- 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
- 'category' => Plan::DEFAULT_CATEGORY,
- )
- );
-
- $this->expectException( \RuntimeException::class );
- ( new ContractFactory() )->create_from_order( $order, $plan );
- }
-
- /**
- * @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.
- $order = new WC_Order();
- $order->set_currency( 'USD' );
-
- $this->expectException( \RuntimeException::class );
- ( new ContractFactory() )->create_from_order( $order, $this->make_plan() );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
index 125be196c7a..78f598cfa2d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/CancellationTest.php
@@ -14,6 +14,8 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati
use DomainException;
use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Subscriptions;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
@@ -60,6 +62,7 @@ class CancellationTest extends EngineIntegrationTestCase {
private function seed( string $status, ?string $end_gmt = null ): int {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => 1,
'status' => $status,
'currency' => 'USD',
@@ -118,7 +121,7 @@ class CancellationTest extends EngineIntegrationTestCase {
$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt() );
$this->assertNull( $stored->get_next_payment_gmt() );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
}
public function test_cancel_at_period_end_on_a_pending_cancellation_contract_is_a_no_op(): void {
@@ -160,6 +163,7 @@ class CancellationTest extends EngineIntegrationTestCase {
public function test_rejects_a_terminal_contract(): void {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => 1,
'status' => ContractStatus::CANCELLED,
'currency' => 'USD',
@@ -193,7 +197,93 @@ class CancellationTest extends EngineIntegrationTestCase {
$stored = $this->reload( $id );
$this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
$this->assertNull( $stored->get_next_payment_gmt() );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
+ }
+
+ /**
+ * The anchor is cleared after the status write has committed, so a failed delete must
+ * not abort the transition: the lifecycle action still fires.
+ *
+ * @dataProvider provide_cancel_modes
+ *
+ * @param string $method Cancellation method under test.
+ * @param string $action Action the method fires.
+ * @param string $status Status the method writes.
+ */
+ public function test_a_failed_anchor_clear_does_not_abort_the_transition( string $method, string $action, string $status ): void {
+ $id = $this->seed_active();
+ ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+
+ $fired = 0;
+ add_action(
+ $action,
+ static function () use ( &$fired ): void {
+ ++$fired;
+ }
+ );
+ $break = $this->break_meta_deletes();
+
+ try {
+ $this->assertTrue( $this->sut->$method( $this->reload( $id ) ) );
+ } finally {
+ remove_filter( 'query', $break );
+ }
+
+ $this->assertSame( 1, $fired, 'The lifecycle action fires.' );
+ $this->assertSame( $status, $this->reload( $id )->get_status() );
+ $this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ), 'The anchor is left behind.' );
+ }
+
+ /**
+ * A transition that loses its compare-and-set leaves the hold anchor in place, so the
+ * contract that won can still resume from it.
+ *
+ * @dataProvider provide_cancel_modes
+ *
+ * @param string $method Cancellation method under test.
+ */
+ public function test_a_lost_race_keeps_the_hold_anchor( string $method ): void {
+ $id = $this->seed_active();
+ ( new Hold( $this->contracts ) )->hold( $this->reload( $id ) );
+ $stale = $this->reload( $id );
+
+ $concurrent = $this->reload( $id );
+ $concurrent->set_status( ContractStatus::EXPIRED );
+ $this->contracts->update( $concurrent );
+
+ try {
+ $this->sut->$method( $stale );
+ $this->fail( 'Expected a DomainException when the conditional write misses.' );
+ } catch ( DomainException $e ) {
+ $this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
+ }
+ }
+
+ /**
+ * Both cancellation modes: method, action, resulting status.
+ *
+ * @return array<string, array{0: string, 1: string, 2: string}>
+ */
+ public function provide_cancel_modes(): array {
+ return array(
+ 'cancel' => array( 'cancel', Cancellation::CONTRACT_CANCELLED_ACTION, ContractStatus::CANCELLED ),
+ 'cancel at period end' => array( 'cancel_at_period_end', Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION, ContractStatus::PENDING_CANCELLATION ),
+ );
+ }
+
+ /**
+ * Make every contract meta DELETE fail until the returned filter is removed.
+ *
+ * @return callable The `query` filter to remove.
+ */
+ private function break_meta_deletes(): callable {
+ $meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+ $break = static function ( string $query ) use ( $meta_table ): string {
+ return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
+ };
+ add_filter( 'query', $break );
+
+ return $break;
}
public function test_cancel_accepts_a_pending_cancellation_contract(): void {
@@ -206,6 +296,33 @@ class CancellationTest extends EngineIntegrationTestCase {
$this->assertNull( $stored->get_next_payment_gmt() );
}
+ public function test_cancel_accepts_a_draft_contract(): void {
+ // A stuck draft (created, never activated) has no cycles and no due moment.
+ $id = Contracts::create(
+ array(
+ 'extension_slug' => 'test-owner',
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ )
+ )->get_id();
+ $this->assertSame( ContractStatus::DRAFT, $this->reload( $id )->get_status() );
+
+ $fired = 0;
+ add_action(
+ Cancellation::CONTRACT_CANCELLED_ACTION,
+ static function () use ( &$fired ): void {
+ ++$fired;
+ }
+ );
+
+ $this->assertTrue( Subscriptions::cancel( $id ) );
+
+ $stored = $this->reload( $id );
+ $this->assertSame( ContractStatus::CANCELLED, $stored->get_status() );
+ $this->assertNull( $stored->get_next_payment_gmt() );
+ $this->assertSame( 1, $fired );
+ }
+
public function test_cancel_on_a_cancelled_contract_is_a_no_op_that_refires_the_action(): void {
$id = $this->seed( ContractStatus::CANCELLED );
@@ -253,21 +370,22 @@ class CancellationTest extends EngineIntegrationTestCase {
public function test_cancel_at_period_end_ignores_a_malformed_hold_anchor(): void {
$id = $this->seed( ContractStatus::ON_HOLD );
- $this->contracts->update( $this->with_hold_anchor( $this->reload( $id ), 'not-a-date' ) );
+ $this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );
$this->sut->cancel_at_period_end( $this->reload( $id ) );
$stored = $this->reload( $id );
$this->assertSame( ContractStatus::PENDING_CANCELLATION, $stored->get_status() );
$this->assertSame( '2099-01-01 00:00:00', $stored->get_end_gmt(), 'The stored next payment, not the malformed anchor, is the period end.' );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
}
public function test_cancel_at_period_end_from_on_hold_without_a_date_leaves_no_end(): void {
$id = $this->seed( ContractStatus::ON_HOLD );
$held = $this->reload( $id );
$held->set_next_payment_gmt( null );
- $this->contracts->update( $this->with_hold_anchor( $held, 'not-a-date' ) );
+ $this->contracts->update( $held );
+ $this->contracts->update_meta( $id, Hold::ANCHOR_META_KEY, 'not-a-date' );
$this->sut->cancel_at_period_end( $this->reload( $id ) );
@@ -276,18 +394,6 @@ class CancellationTest extends EngineIntegrationTestCase {
$this->assertNull( $stored->get_end_gmt(), 'A malformed anchor is never written as the end date.' );
}
- /**
- * Set the hold anchor meta on a contract.
- *
- * @param Contract $contract Contract to change.
- * @param string $anchor Anchor value to store.
- */
- private function with_hold_anchor( Contract $contract, string $anchor ): Contract {
- $contract->set_meta( Hold::ANCHOR_META_KEY, $anchor );
-
- return $contract;
- }
-
public function test_cancel_at_period_end_rejects_an_expired_contract(): void {
$id = $this->seed( ContractStatus::EXPIRED );
$before = did_action( Cancellation::CONTRACT_PENDING_CANCELLATION_ACTION );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
index 5880d933451..1973b59a226 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/HoldTest.php
@@ -48,6 +48,7 @@ class HoldTest extends EngineIntegrationTestCase {
private function seed( string $status, ?string $next_payment = '2099-01-01 00:00:00' ): int {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => 1,
'status' => $status,
'currency' => 'USD',
@@ -61,6 +62,16 @@ class HoldTest extends EngineIntegrationTestCase {
return $this->contracts->insert( $contract );
}
+ /**
+ * The stored hold anchor for a contract ('' when absent).
+ *
+ * @param int $id Contract id.
+ * @return mixed
+ */
+ private function anchor( int $id ) {
+ return $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true );
+ }
+
public function test_hold_suspends_billing_by_moving_to_on_hold(): void {
$id = $this->seed( ContractStatus::ACTIVE );
@@ -78,7 +89,7 @@ class HoldTest extends EngineIntegrationTestCase {
$held = $this->reload( $id );
$this->assertNull( $held->get_next_payment_gmt() );
- $this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
+ $this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
}
public function test_hold_without_a_next_payment_stores_no_anchor(): void {
@@ -89,7 +100,7 @@ class HoldTest extends EngineIntegrationTestCase {
$held = $this->reload( $id );
$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
$this->assertNull( $held->get_next_payment_gmt() );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $held->get_meta() );
+ $this->assertSame( '', $this->anchor( $id ) );
}
public function test_hold_on_an_on_hold_contract_is_an_idempotent_no_op(): void {
@@ -111,7 +122,19 @@ class HoldTest extends EngineIntegrationTestCase {
$this->assertSame( 1, $fired );
$this->assertSame( ContractStatus::ON_HOLD, $held->get_status() );
$this->assertNull( $held->get_next_payment_gmt() );
- $this->assertSame( '2099-01-01 00:00:00', $held->get_meta()[ Hold::ANCHOR_META_KEY ] ?? null );
+ $this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
+ }
+
+ public function test_the_anchor_survives_a_whole_contract_update(): void {
+ $id = $this->seed( ContractStatus::ACTIVE );
+ $this->sut->hold( $this->reload( $id ) );
+
+ $held = $this->reload( $id );
+ $held->set_end_gmt( '2099-12-31 00:00:00' );
+ $this->contracts->update( $held );
+
+ $this->assertSame( '2099-01-01 00:00:00', $this->anchor( $id ) );
+ $this->assertSame( '2099-01-01 00:00:00', Hold::read_anchor( $this->contracts, $id ) );
}
public function test_hold_fires_the_held_action(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
index 606d1c1d4d1..e3eabcbedfb 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Contracts/ReactivationTest.php
@@ -96,6 +96,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
private function seed_on_hold( ?string $next_payment_gmt, int $selling_plan_id, string $status = ContractStatus::ON_HOLD, array $meta = array() ): int {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => 1,
'status' => $status,
'currency' => 'USD',
@@ -104,11 +105,15 @@ class ReactivationTest extends EngineIntegrationTestCase {
'start_gmt' => '2026-01-01 00:00:00',
'next_payment_gmt' => $next_payment_gmt,
'billing_total' => '19.99',
- 'meta' => $meta,
)
);
- return $this->contracts->insert( $contract );
+ $id = $this->contracts->insert( $contract );
+ foreach ( $meta as $key => $value ) {
+ $this->contracts->add_meta( $id, $key, $value );
+ }
+
+ return $id;
}
private function utc( string $datetime ): DateTimeImmutable {
@@ -156,7 +161,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
$stored = $this->reload( $id );
$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
$this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
}
public function test_reactivate_rolls_a_past_due_anchor_forward(): void {
@@ -168,7 +173,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
$stored = $this->reload( $id );
$this->assertSame( '2026-05-01 00:00:00', $stored->get_next_payment_gmt() );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
}
/**
@@ -181,7 +186,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
$stored = $this->reload( $id );
$this->assertSame( '2099-06-01 00:00:00', $stored->get_next_payment_gmt() );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
}
public function test_reactivate_ignores_a_malformed_anchor(): void {
@@ -192,7 +197,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
$stored = $this->reload( $id );
$this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
$this->assertNull( $stored->get_next_payment_gmt(), 'A malformed anchor counts as absent.' );
- $this->assertArrayNotHasKey( Hold::ANCHOR_META_KEY, $stored->get_meta() );
+ $this->assertSame( '', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
}
public function test_reactivate_rolls_by_the_frozen_snapshot_cadence_over_the_live_plan(): void {
@@ -264,6 +269,54 @@ class ReactivationTest extends EngineIntegrationTestCase {
$this->assertSame( 1, $fired );
}
+ /**
+ * The anchor is cleared after the status write has committed, so a failed delete must
+ * not abort the reactivation, which could not be retried on an active contract.
+ */
+ public function test_a_failed_anchor_clear_does_not_abort_the_reactivation(): void {
+ $id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
+
+ $fired = 0;
+ add_action(
+ Reactivation::CONTRACT_REACTIVATED_ACTION,
+ static function () use ( &$fired ): void {
+ ++$fired;
+ }
+ );
+ $meta_table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+ $break = static function ( string $query ) use ( $meta_table ): string {
+ return 0 === strpos( $query, "DELETE FROM `{$meta_table}`" ) ? 'SELECT broken syntax (' : $query;
+ };
+ add_filter( 'query', $break );
+
+ try {
+ $this->assertTrue( $this->sut->reactivate( $this->reload( $id ), $this->utc( '2026-06-01 00:00:00' ) ) );
+ } finally {
+ remove_filter( 'query', $break );
+ }
+
+ $stored = $this->reload( $id );
+ $this->assertSame( 1, $fired, 'The reactivated action fires.' );
+ $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+ $this->assertSame( '2099-01-01 00:00:00', $stored->get_next_payment_gmt() );
+ }
+
+ public function test_a_lost_race_keeps_the_hold_anchor(): void {
+ $id = $this->seed_on_hold( null, $this->make_monthly_plan(), ContractStatus::ON_HOLD, array( Hold::ANCHOR_META_KEY => '2099-01-01 00:00:00' ) );
+ $stale = $this->reload( $id );
+
+ $concurrent = $this->reload( $id );
+ $concurrent->set_status( ContractStatus::CANCELLED );
+ $this->contracts->update( $concurrent );
+
+ try {
+ $this->sut->reactivate( $stale, $this->utc( '2026-06-01 00:00:00' ) );
+ $this->fail( 'Expected a DomainException when the conditional write misses.' );
+ } catch ( DomainException $e ) {
+ $this->assertSame( '2099-01-01 00:00:00', $this->contracts->get_meta( $id, Hold::ANCHOR_META_KEY, true ) );
+ }
+ }
+
public function test_reactivate_rejects_an_already_active_contract(): void {
// An active contract past its due date must NOT reach the recompute: rolling its
// date forward would skip the charge the due scan owes it.
@@ -301,6 +354,7 @@ class ReactivationTest extends EngineIntegrationTestCase {
public function test_reactivate_rejects_a_terminal_contract(): void {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => 1,
'status' => ContractStatus::CANCELLED,
'currency' => 'USD',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
index c5380f92158..6f66130bd5d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/OwnerScopedDueScanTest.php
@@ -27,7 +27,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Hold;
@@ -148,7 +147,7 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
}
/**
- * Sign up a monthly contract owned by `$owner` via the checkout factory (cycle 1 billed,
+ * Sign up a monthly contract owned by `$owner` through the contracts facade (cycle 1 billed,
* next payment due {@see self::FIRST_DUE}). Returns the contract id.
*
* @param string $owner The plan's (and so the contract's) extension slug.
@@ -171,8 +170,7 @@ class OwnerScopedDueScanTest extends EngineIntegrationTestCase {
$order->set_date_paid( '2026-01-15 00:00:00' );
$order->save();
- $contract = ( new ContractFactory() )->create_from_order( $order, $plan );
- $id = (int) $contract->get_id();
+ $id = $this->sign_up_from_order( $order, $plan );
$this->assertSame( self::FIRST_DUE, $this->reload( $id )->get_next_payment_gmt() );
return $id;
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
index 660dfba037e..c39089d3004 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Renewal/RenewalDispatcherTest.php
@@ -13,6 +13,7 @@ use DateTimeImmutable;
use DateTimeZone;
use EngineIntegrationTestCase;
use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -20,7 +21,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal\RenewalDispatcher;
@@ -66,7 +66,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
}
/**
- * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+ * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
*/
private function make_plan_object(): Plan {
$plan = Plan::create(
@@ -83,7 +83,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
}
/**
- * Sign up a contract via the checkout factory so its billing chain holds cycle 1 (billed),
+ * Sign up a contract through the contracts facade so its billing chain holds cycle 1 (billed),
* with its next payment due at the given date.
*
* @param string $gateway Gateway id stamped on the order/contract.
@@ -100,12 +100,14 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
$order->set_date_paid( '2026-01-15 00:00:00' );
$order->save();
- $contract = ( new ContractFactory() )->create_from_order( $order, $plan );
+ $id = $this->sign_up_from_order( $order, $plan );
- // The factory anchors the first renewal off the plan cadence; pin the schedule date
+ // Sign-up anchors the first renewal off the plan cadence; pin the schedule date
// the test reasons about so due/not-due is explicit.
- $contract->set_next_payment_gmt( $next_payment_gmt );
- ( new ContractRepository() )->update( $contract );
+ Contracts::update( $id, array( 'next_payment_gmt' => $next_payment_gmt ) );
+
+ $contract = ( new ContractRepository() )->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
return $contract;
}
@@ -129,7 +131,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
// The contract was not advanced: still cycle 1, schedule unmoved.
$repo = new ContractRepository();
- $this->assertSame( 1, $repo->max_count( $contract_id ) );
+ $this->assertSame( 1, $this->head_count( $contract_id ) );
$reloaded = $repo->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
@@ -183,7 +185,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
// Untouched: still cycle 1, schedule unmoved, no renewal order.
$repo = new ContractRepository();
- $this->assertSame( 1, $repo->max_count( $contract_id ) );
+ $this->assertSame( 1, $this->head_count( $contract_id ) );
$reloaded = $repo->find( $contract_id );
$this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( '2026-06-15 00:00:00', $reloaded->get_next_payment_gmt() );
@@ -209,14 +211,14 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
$this->assertSame( 2, $processed, 'A single tick processes at most the batch size.' );
// The two oldest-due contracts advanced; the third is still at cycle 1.
- $this->assertSame( 2, $repo->max_count( (int) $first->get_id() ) );
- $this->assertSame( 2, $repo->max_count( (int) $second->get_id() ) );
- $this->assertSame( 1, $repo->max_count( (int) $third->get_id() ) );
+ $this->assertSame( 2, $this->head_count( (int) $first->get_id() ) );
+ $this->assertSame( 2, $this->head_count( (int) $second->get_id() ) );
+ $this->assertSame( 1, $this->head_count( (int) $third->get_id() ) );
// The next tick drains the remaining due contract.
$processed_next = $dispatcher->run_batch( $this->scan_now(), 2 );
$this->assertSame( 1, $processed_next );
- $this->assertSame( 2, $repo->max_count( (int) $third->get_id() ) );
+ $this->assertSame( 2, $this->head_count( (int) $third->get_id() ) );
}
/**
@@ -306,7 +308,7 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
$this->assertSame( 0, $dispatcher->run_batch( $this->scan_now(), -5 ) );
// Nothing was renewed by the no-op ticks.
- $this->assertSame( 1, ( new ContractRepository() )->max_count( (int) $contract->get_id() ) );
+ $this->assertSame( 1, $this->head_count( (int) $contract->get_id() ) );
}
/**
@@ -360,4 +362,15 @@ class RenewalDispatcherTest extends EngineIntegrationTestCase {
)
);
}
+
+ /**
+ * The billing chain head's count, or null for an empty chain.
+ *
+ * @param int $contract_id Contract id.
+ */
+ private function head_count( int $contract_id ): ?int {
+ $head = ( new ContractRepository() )->find_chain_head( $contract_id );
+
+ return null === $head ? null : $head->get_count();
+ }
}
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 42f7d0be5bc..2ac1e894e24 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
@@ -11,6 +11,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati
use EngineIntegrationTestCase;
use WC_Order;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
@@ -18,7 +19,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership\ConsumerRegistry;
@@ -110,7 +110,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * Persist a monthly plan and return the entity (the ContractFactory needs the plan).
+ * Persist a monthly plan and return the entity (the sign-up helper needs the plan).
*
* @param int|null $max_cycles Maximum billing cycles, or null for open-ended.
*/
@@ -129,7 +129,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
}
/**
- * Sign up a contract via the checkout factory so its billing chain holds cycle 1
+ * Sign up a contract through the contracts facade so its billing chain holds cycle 1
* (billed), the starting point the renewal advances from.
*
* @param string $gateway Gateway id stamped on the order/contract.
@@ -146,7 +146,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order->set_date_paid( '2026-01-15 00:00:00' );
$order->save();
- return ( new ContractFactory() )->create_from_order( $order, $plan );
+ return $this->reload_contract( $this->sign_up_from_order( $order, $plan ) );
}
private function make_origin_order(): WC_Order {
@@ -166,6 +166,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
// so the renewal amount resolves off the current cycle.
$contract = Contract::create(
array(
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => $plan_id,
@@ -446,9 +447,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order->add_item( $line );
$order->save();
- $contract = ( new ContractFactory() )->create_from_order( $order, $this->make_plan_object() );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
+ $contract_id = $this->sign_up_from_order( $order, $this->make_plan_object() );
$renewal_order = $this->run_scheduled_renewal( $contract_id );
$this->assertInstanceOf( WC_Order::class, $renewal_order );
@@ -752,32 +751,6 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
}
- /**
- * @testdox the scheduled scan renews from the contract's own plan snapshot even when the live plan is deleted.
- *
- * The contract's frozen snapshot is the cadence source of truth, so a deleted live selling
- * plan no longer blocks the renewal - the chain advances on the snapshot's terms.
- */
- public function test_scheduled_renewal_renews_from_contract_snapshot_when_live_plan_deleted(): void {
- $this->approve_charges_for( self::GATEWAY_APPROVING );
-
- $contract = $this->sign_up_contract( self::GATEWAY_APPROVING );
- $contract_id = $contract->get_id();
- $this->assertNotNull( $contract_id );
-
- // Delete the live selling plan; the contract keeps its frozen snapshot.
- ( new PlanRepository() )->delete( $contract->get_selling_plan_id() );
-
- $renewal_order = $this->run_scheduled_renewal( $contract_id );
- $this->assertInstanceOf( WC_Order::class, $renewal_order );
-
- // Cycle 2 was billed from the snapshot's cadence.
- $cycle = ( new ContractRepository() )->find_chain_head( $contract_id );
- $this->assertInstanceOf( Cycle::class, $cycle );
- $this->assertSame( 2, $cycle->get_count() );
- $this->assertTrue( $cycle->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
- }
-
/**
* @testdox the scheduled scan expires the contract when it hits max cycles.
*/
@@ -900,6 +873,8 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order = $this->make_origin_order();
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => $plan_id,
@@ -1524,6 +1499,114 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-02-15 00:00:00', $reloaded->get_next_payment_gmt() );
}
+ /**
+ * Create an active, overdue contract through the facade, minus the given renewal input.
+ *
+ * @param string $missing One of currency, customer, payment_method, billing_policy.
+ */
+ private function seed_contract_missing( string $missing ): int {
+ $customer = self::factory()->user->create();
+ $this->assertIsInt( $customer );
+
+ $args = array(
+ 'extension_slug' => 'engine-tests',
+ 'customer_id' => $customer,
+ 'currency' => 'USD',
+ 'payment_method' => self::GATEWAY_APPROVING,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ 'billing_total' => '10',
+ 'selling_plan_id' => $this->make_plan(),
+ );
+
+ switch ( $missing ) {
+ case 'currency':
+ unset( $args['currency'], $args['billing_total'] );
+ break;
+ case 'customer':
+ unset( $args['customer_id'] );
+ break;
+ case 'billing_policy':
+ unset( $args['selling_plan_id'] );
+ break;
+ default:
+ unset( $args[ $missing ] );
+ }
+
+ $id = Contracts::create( $args )->get_id();
+ Contracts::add_cycle(
+ $id,
+ array(
+ 'status' => CycleStatus::BILLED,
+ 'count' => 1,
+ 'currency' => 'USD',
+ 'starts_at_gmt' => '2026-01-01 00:00:00',
+ 'ends_at_gmt' => '2026-02-01 00:00:00',
+ )
+ );
+ Contracts::update( $id, array( 'status' => ContractStatus::ACTIVE ) );
+
+ return $id;
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_missing_renewal_inputs(): array {
+ return array(
+ 'currency' => array( 'currency' ),
+ 'payment method' => array( 'payment_method' ),
+ 'billing policy' => array( 'billing_policy' ),
+ );
+ }
+
+ /**
+ * @testdox the scheduled scan parks a contract missing a renewal input instead of erroring.
+ * @dataProvider provide_missing_renewal_inputs
+ *
+ * @param string $missing The missing renewal input.
+ */
+ public function test_scheduled_renewal_parks_a_contract_missing_a_renewal_input( string $missing ): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+ $contract_id = $this->seed_contract_missing( $missing );
+
+ $this->assertNull( $this->run_scheduled_renewal( $contract_id ) );
+
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+ $this->assertNull( $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'Parked out of the due set.' );
+ }
+
+ /**
+ * @testdox a contract with no customer renews as a guest order.
+ */
+ public function test_a_contract_without_a_customer_renews_as_a_guest_order(): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+ $contract_id = $this->seed_contract_missing( 'customer' );
+
+ $order = $this->run_scheduled_renewal( $contract_id );
+
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertSame( 0, $order->get_customer_id() );
+ $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+ $this->assertSame( '2026-03-01 00:00:00', $this->reload_contract( $contract_id )->get_next_payment_gmt(), 'The next-due moment advanced.' );
+ }
+
+ /**
+ * @testdox renew_now refuses a contract missing a renewal input without parking it.
+ * @dataProvider provide_missing_renewal_inputs
+ *
+ * @param string $missing The missing renewal input.
+ */
+ public function test_renew_now_refuses_a_contract_missing_a_renewal_input( string $missing ): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+ $contract_id = $this->seed_contract_missing( $missing );
+
+ $this->assertNull( ( new RenewalEngine() )->renew_now( $contract_id ) );
+
+ $this->assertCount( 0, $this->renewal_orders_for_cycle( $contract_id, 2 ) );
+ $this->assertSame( '2026-02-01 00:00:00', $this->reload_contract( $contract_id )->get_next_payment_gmt() );
+ }
+
/**
* @testdox a manual paid-status change settles a processing cycle (cash-on-delivery shape).
*
@@ -1749,7 +1832,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order->add_item( $line );
$order->save();
- return ( new ContractFactory() )->create_from_order( $order, $plan );
+ return $this->reload_contract( $this->sign_up_from_order( $order, $plan ) );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
index 2726126eb30..5e69e62d8f1 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractCustomerReadTest.php
@@ -43,6 +43,7 @@ class ContractCustomerReadTest extends EngineIntegrationTestCase {
private function seed( int $customer_id, string $status ): int {
$contract = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => $customer_id,
'status' => $status,
'currency' => 'USD',
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
new file mode 100644
index 00000000000..fe4d6da088d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractMetaTest.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * Integration tests for the multi-value contract meta reads and writes on
+ * ContractRepository (WordPress post-meta semantics).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
+ */
+class ContractMetaTest extends EngineIntegrationTestCase {
+
+ /**
+ * The System Under Test.
+ *
+ * @var ContractRepository
+ */
+ private $sut;
+
+ /**
+ * A stored contract id.
+ *
+ * @var int
+ */
+ private $id;
+
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new ContractRepository();
+ $this->id = $this->sut->insert( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+ }
+
+ public function test_add_meta_keeps_every_value_under_one_key_in_order(): void {
+ $first = $this->sut->add_meta( $this->id, 'note', 'one' );
+ $second = $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertIsInt( $first );
+ $this->assertIsInt( $second );
+ $this->assertGreaterThan( $first, $second );
+ $this->assertSame( array( 'one', 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+ $this->assertSame( 'one', $this->sut->get_meta( $this->id, 'note', true ) );
+ }
+
+ public function test_unique_add_on_an_existing_key_adds_nothing(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+
+ $this->assertNull( $this->sut->add_meta( $this->id, 'note', 'two', true ) );
+ $this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ public function test_update_meta_adds_when_absent(): void {
+ $this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+ $this->assertSame( array( 'one' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ public function test_update_meta_rewrites_every_row_for_the_key(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three' ) );
+ $this->assertSame( array( 'three', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ public function test_update_meta_with_a_previous_value_rewrites_only_matching_rows(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertTrue( $this->sut->update_meta( $this->id, 'note', 'three', 'two' ) );
+ $this->assertSame( array( 'one', 'three' ), $this->sut->get_meta( $this->id, 'note' ) );
+ $this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'four', 'missing' ) );
+ }
+
+ public function test_update_meta_to_the_same_value_reports_no_change(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+
+ $this->assertFalse( $this->sut->update_meta( $this->id, 'note', 'one' ) );
+ }
+
+ public function test_delete_meta_with_a_value_removes_only_that_row(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertTrue( $this->sut->delete_meta( $this->id, 'note', 'one' ) );
+ $this->assertSame( array( 'two' ), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ public function test_delete_meta_without_a_value_removes_every_row_for_the_key(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+ $this->sut->add_meta( $this->id, 'other', 'kept' );
+
+ $this->assertTrue( $this->sut->delete_meta( $this->id, 'note' ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+ $this->assertSame( array( 'kept' ), $this->sut->get_meta( $this->id, 'other' ) );
+ $this->assertFalse( $this->sut->delete_meta( $this->id, 'note' ) );
+ }
+
+ public function test_get_meta_without_a_key_groups_every_key(): void {
+ $this->sut->add_meta( $this->id, 'note', 'one' );
+ $this->sut->add_meta( $this->id, 'flag', 'yes' );
+ $this->sut->add_meta( $this->id, 'note', 'two' );
+
+ $this->assertSame(
+ array(
+ 'note' => array( 'one', 'two' ),
+ 'flag' => array( 'yes' ),
+ ),
+ $this->sut->get_meta( $this->id )
+ );
+ }
+
+ public function test_arrays_round_trip_through_serialization(): void {
+ $value = array(
+ 'a' => 1,
+ 'b' => array( 'c' ),
+ );
+
+ $this->sut->add_meta( $this->id, 'payload', $value );
+
+ $this->assertSame( $value, $this->sut->get_meta( $this->id, 'payload', true ) );
+ $this->assertTrue( $this->sut->delete_meta( $this->id, 'payload', $value ) );
+ }
+
+ public function test_missing_key_reads_as_empty(): void {
+ $this->assertSame( '', $this->sut->get_meta( $this->id, 'missing', true ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id, 'missing' ) );
+ $this->assertSame( array(), $this->sut->get_meta( $this->id ) );
+ }
+
+ public function test_meta_is_scoped_to_its_contract(): void {
+ $other = $this->sut->insert( Contract::create( array( 'extension_slug' => 'acme-subs' ) ) );
+ $this->sut->add_meta( $other, 'note', 'theirs' );
+
+ $this->assertSame( array(), $this->sut->get_meta( $this->id, 'note' ) );
+ }
+
+ /**
+ * @dataProvider provide_write_methods
+ *
+ * @param string $method Write method name.
+ */
+ public function test_an_empty_key_is_rejected_on_writes( string $method ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ $this->sut->{$method}( $this->id, '', 'value' );
+ }
+
+ /**
+ * @return array<string, array{0: string}>
+ */
+ public function provide_write_methods(): array {
+ return array(
+ 'add' => array( 'add_meta' ),
+ 'update' => array( 'update_meta' ),
+ 'delete' => array( 'delete_meta' ),
+ );
+ }
+}
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 767b88862b9..5fc3bb0a965 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
@@ -21,6 +21,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepos
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\RenewalCandidate;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
/**
* @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository
@@ -52,6 +53,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
private function make_contract(): Contract {
return Contract::create(
array(
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 42,
'currency' => 'USD',
'selling_plan_id' => 7,
@@ -93,9 +95,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'country' => 'US',
),
),
- 'meta' => array(
- 'source_channel' => 'pdp',
- ),
)
);
}
@@ -190,8 +189,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertArrayHasKey( Contract::ADDRESS_BILLING, $addresses );
$this->assertArrayHasKey( Contract::ADDRESS_SHIPPING, $addresses );
$this->assertSame( 'Ada', $addresses[ Contract::ADDRESS_BILLING ]['first_name'] );
-
- $this->assertSame( 'pdp', $fetched->get_meta()['source_channel'] );
}
/**
@@ -206,7 +203,75 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$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 find_by_origin_order returns the matching contracts oldest first, with plan terms hydrated.
+ */
+ public function test_find_by_origin_order_returns_matches_oldest_first(): void {
+ $first = $this->sut->insert( $this->make_contract() );
+ $second = $this->sut->insert( $this->make_contract() );
+ $other = $this->make_contract();
+ $other->set_origin_order_id( 2002 );
+ $this->sut->insert( $other );
+
+ $first_contract = $this->sut->find( $first );
+ $this->assertInstanceOf( Contract::class, $first_contract );
+ $this->seed_plan_snapshot(
+ $first_contract,
+ array(
+ 'selling_plan_id' => 7,
+ 'name' => 'Monthly',
+ )
+ );
+
+ $found = $this->sut->find_by_origin_order( 1001 );
+
+ $this->assertSame(
+ array( $first, $second ),
+ array_map(
+ static function ( Contract $c ): ?int {
+ return $c->get_id();
+ },
+ $found
+ )
+ );
+ $snapshot = $found[0]->get_plan_snapshot();
+ $this->assertInstanceOf( PlanSnapshot::class, $snapshot );
+ $this->assertSame( 'Monthly', $snapshot->to_array()['name'] );
+ $this->assertSame( array(), $this->sut->find_by_origin_order( 9999 ) );
+ }
+
+ /**
+ * Store a plan snapshot row and point the contract at it.
+ *
+ * @param Contract $contract Stored contract.
+ * @param array<string, mixed> $payload Plan snapshot payload.
+ */
+ private function seed_plan_snapshot( Contract $contract, array $payload ): void {
+ $plan = PlanSnapshot::from_array( $payload );
+ $contract->set_plan_snapshot_id(
+ ( new SnapshotStore() )->insert( (int) $contract->get_id(), SnapshotStore::TYPE_PLAN, $plan->get_selling_plan_id(), $plan->to_payload(), $plan->get_schema_version() )
+ );
+ $this->sut->update_fields( $contract, array( 'plan_snapshot_id' ) );
+ }
+
+ /**
+ * @testdox Meta written through the meta methods survives whole-contract writes.
+ */
+ public function test_meta_survives_a_whole_entity_update(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+ $this->sut->add_meta( $id, 'source_channel', 'pdp' );
+
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+ $contract->set_status( ContractStatus::ON_HOLD );
+ $this->assertTrue( $this->sut->update( $contract ) );
+ $this->assertSame( 'pdp', $this->sut->get_meta( $id, 'source_channel', true ) );
+
+ $contract->set_status( ContractStatus::ACTIVE );
+ $this->assertTrue( $this->sut->update_if_status( $contract, ContractStatus::ON_HOLD ) );
+ $this->assertSame( array( 'pdp' ), $this->sut->get_meta( $id, 'source_channel' ) );
}
/**
@@ -258,6 +323,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
return $this->sut->insert(
Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
'customer_id' => $customer_id,
'status' => $status,
'currency' => 'USD',
@@ -525,7 +591,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$counts = $this->sut->count_by_status();
$this->assertSame( ContractStatus::get_all(), array_keys( $counts ) );
- $this->assertSame( array( 0, 0, 0, 0, 0 ), array_values( $counts ) );
+ $this->assertSame( array_fill( 0, count( $counts ), 0 ), array_values( $counts ) );
}
/**
@@ -614,6 +680,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$id = $this->sut->insert(
Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 1,
'currency' => 'EUR',
'selling_plan_id' => 2,
@@ -628,41 +696,16 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
}
/**
- * @testdox insert_with_origin_cycle records cycle 1's snapshot refs on the contract too.
+ * @testdox A stored row with no extension_slug hydrates a null slug.
*/
- 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( new CycleStatus( 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_chain_head( $id );
- $this->assertInstanceOf( Cycle::class, $current );
- $this->assertSame( 1, $current->get_count() );
- $this->assertTrue( $current->get_status()->equals( new CycleStatus( CycleStatus::BILLED ) ) );
- }
+ public function test_a_stored_null_extension_slug_hydrates_as_null(): void {
+ global $wpdb;
- /**
- * @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(
+ 'extension_slug' => 'engine-tests',
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 1,
'currency' => 'EUR',
'selling_plan_id' => 2,
@@ -672,6 +715,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
)
);
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'extension_slug' => null ), array( 'id' => $id ) );
+
$fetched = $this->sut->find( $id );
$this->assertInstanceOf( Contract::class, $fetched );
$this->assertNull( $fetched->get_extension_slug() );
@@ -728,6 +774,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$mutated = Contract::create(
array(
+ 'extension_slug' => 'engine-tests',
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 42,
'currency' => 'USD',
'selling_plan_id' => 7,
@@ -743,7 +791,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
'total' => '24.00',
),
),
- 'meta' => array( 'source_channel' => 'email' ),
)
);
$mutated->set_id( $id );
@@ -755,7 +802,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$items = $reloaded->get_items();
$this->assertCount( 1, $items );
$this->assertSame( 'Tea tin', $items[0]['item_name'] );
- $this->assertSame( 'email', $reloaded->get_meta()['source_channel'] );
}
/**
@@ -792,6 +838,50 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertSame( '0', $remaining );
}
+ /**
+ * @testdox update_fields on a deleted contract returns false before any child write.
+ */
+ public function test_update_fields_returns_false_for_a_deleted_contract_and_writes_no_children(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $this->assertTrue( $this->sut->delete( $id ) );
+
+ $stale = $this->make_contract();
+ $stale->set_id( $id );
+
+ $this->assertFalse( $this->sut->update_fields( $stale, array( 'status', 'items', 'addresses' ) ) );
+
+ foreach ( array( SchemaInstaller::TABLE_CONTRACT_ITEMS, SchemaInstaller::TABLE_CONTRACT_ADDRESSES ) as $table ) {
+ $table = SchemaInstaller::get_table_name( $table );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $this->assertSame( '0', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE contract_id = %d", $id ) ) );
+ }
+ }
+
+ /**
+ * @testdox update_fields writing identical values (no changed rows) does not throw.
+ */
+ public function test_update_fields_with_identical_values_does_not_throw(): void {
+ global $wpdb;
+
+ $id = $this->sut->insert( $this->make_contract() );
+ $contract = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
+
+ // A write in a new second changes date_updated_gmt; repeat until one changes no rows.
+ $zero_rows = false;
+ for ( $i = 0; $i < 3 && ! $zero_rows; $i++ ) {
+ $this->assertTrue( $this->sut->update_fields( $contract, array( 'status', 'next_payment_gmt' ) ) );
+ $zero_rows = 0 === $wpdb->rows_affected;
+ }
+
+ $this->assertTrue( $zero_rows, 'A write changed no rows.' );
+ $stored = $this->sut->find( $id );
+ $this->assertInstanceOf( Contract::class, $stored );
+ $this->assertSame( ContractStatus::ACTIVE, $stored->get_status() );
+ }
+
/**
* @testdox append_cycle inserts a cycle reachable as the chain's current cycle.
*/
@@ -901,27 +991,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$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.
*/
@@ -1129,9 +1198,13 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
* @testdox find_due skips a due contract with no owner.
*/
public function test_find_due_skips_a_contract_with_no_owner(): void {
+ global $wpdb;
+
$now = new \DateTimeImmutable( '2026-07-15 00:00:00', new \DateTimeZone( 'UTC' ) );
$owned = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
- $no_owner = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE, Contract::SCHEDULE_SOURCE_PRIMITIVE, CycleStatus::BILLED, null, null, null );
+ $no_owner = $this->insert_contract_due_at( '2026-06-15 00:00:00', ContractStatus::ACTIVE );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update( SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS ), array( 'extension_slug' => null ), array( 'id' => $no_owner ) );
$ids = $this->due_ids( $now, 50 );
@@ -1444,7 +1517,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
* @param string $head_status The head cycle status (a CycleStatus value).
* @param string|null $claimed_until The head cycle lease expiry, or null for none.
* @param string|null $head_ends_at The head period end; defaults to `$next_payment_gmt`.
- * @param string|null $owner The owning extension slug; defaults to the registered test owner.
+ * @param string $owner The owning extension slug; defaults to the registered test owner.
*/
private function insert_contract_due_at(
?string $next_payment_gmt,
@@ -1453,7 +1526,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
string $head_status = CycleStatus::BILLED,
?string $claimed_until = null,
?string $head_ends_at = null,
- ?string $owner = self::OWNER
+ string $owner = self::OWNER
): int {
$contract = Contract::create(
array(
@@ -1628,6 +1701,60 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->sut->append_cycle( $this->make_cycle( $id, 2, 1, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
}
+ /**
+ * Build a billing cycle whose position the append assigns.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, mixed> $overrides Extra or replacement keys.
+ */
+ private function make_unpositioned_cycle( int $contract_id, array $overrides = array() ): Cycle {
+ return Cycle::create(
+ array_merge(
+ array(
+ 'contract_id' => $contract_id,
+ 'starts_at_gmt' => '2026-07-15 00:00:00',
+ 'ends_at_gmt' => '2026-08-15 00:00:00',
+ 'currency' => 'USD',
+ ),
+ $overrides
+ )
+ );
+ }
+
+ /**
+ * @testdox append_cycle assigns sequence 1 to the first unpositioned cycle and leaves the count alone.
+ */
+ public function test_append_cycle_assigns_the_first_sequence_no(): void {
+ $id = $this->sut->insert( $this->make_contract() );
+ $cycle = $this->make_unpositioned_cycle( $id );
+
+ $this->sut->append_cycle( $cycle );
+
+ $this->assertSame( 1, $cycle->get_sequence_no() );
+ $this->assertNull( $cycle->get_count() );
+ $head = $this->sut->find_chain_head( $id );
+ $this->assertInstanceOf( Cycle::class, $head );
+ $this->assertSame( $cycle->get_id(), $head->get_id() );
+ }
+
+ /**
+ * @testdox append_cycle assigns the head's sequence + 1 within the cycle's own chain.
+ */
+ public function test_append_cycle_assigns_the_next_sequence_no_in_its_chain(): void {
+ $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->sut->append_cycle( $this->make_cycle( $id, 2, 2, '2026-08-15 00:00:00', '2026-09-15 00:00:00' ) );
+
+ $next = $this->make_unpositioned_cycle( $id, array( 'count' => 3 ) );
+ $other = $this->make_unpositioned_cycle( $id, array( 'kind' => 'shipping' ) );
+ $this->sut->append_cycle( $next );
+ $this->sut->append_cycle( $other );
+
+ $this->assertSame( 3, $next->get_sequence_no() );
+ $this->assertSame( 3, $next->get_count() );
+ $this->assertSame( 1, $other->get_sequence_no() );
+ }
+
/**
* @testdox Multiple non-counting cycles (count = null) coexist in one chain.
*/
@@ -1643,9 +1770,6 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$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 ) );
}
/**
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 78bd8fa2528..bb9251991f9 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,7 +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\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
/**
@@ -251,10 +251,44 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
}
/**
- * @testdox The schema version is 2.4.0 (owner-scoped due scan).
+ * @testdox The schema version is 2.5.0 (nullable contract identity columns, HPOS-style meta indexes).
*/
- public function test_schema_version_is_2_4_0(): void {
- $this->assertSame( '2.4.0', SchemaInstaller::get_version() );
+ public function test_schema_version_is_2_5_0(): void {
+ $this->assertSame( '2.5.0', SchemaInstaller::get_version() );
+ }
+
+ /**
+ * @testdox Contract identity columns are nullable until the extension supplies them.
+ */
+ public function test_contracts_identity_columns_are_nullable(): void {
+ global $wpdb;
+
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACTS );
+
+ foreach ( array( 'customer_id', 'currency', 'selling_plan_id', 'start_gmt' ) as $column ) {
+ // 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", $column ), ARRAY_A );
+
+ $this->assertIsArray( $row, "Expected a contracts.{$column} column." );
+ $this->assertSame( 'YES', $row['Null'] ?? null, "Expected contracts.{$column} to be NULLable." );
+ }
+ }
+
+ /**
+ * @testdox Contract meta carries the HPOS-style key/value indexes and no contract_key index.
+ */
+ public function test_contract_meta_has_hpos_style_indexes(): void {
+ $table = SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_META );
+ $indexes = $this->index_names( $table );
+
+ $this->assertContains( 'meta_key_value', $indexes );
+ $this->assertContains( 'contract_meta_key_value', $indexes );
+ $this->assertNotContains( 'contract_key', $indexes );
+ $this->assertSame( array( 'meta_key', 'meta_value' ), $this->index_columns( $table, 'meta_key_value' ) );
+ $this->assertSame(
+ array( 'contract_id', 'meta_key', 'meta_value' ),
+ $this->index_columns( $table, 'contract_meta_key_value' )
+ );
}
public function test_cycles_table_has_expected_columns(): void {
@@ -501,7 +535,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
$names = array();
foreach ( is_array( $rows ) ? $rows : array() as $row ) {
if ( is_array( $row ) ) {
- $names[] = ScalarCoercion::coerce_string( $row['Key_name'] ?? null );
+ $names[] = Coercion::coerce_string( $row['Key_name'] ?? null );
}
}
@@ -525,8 +559,8 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
usort(
$rows,
static function ( $a, $b ): int {
- $a_seq = is_array( $a ) ? ScalarCoercion::coerce_int( $a['Seq_in_index'] ?? null ) : 0;
- $b_seq = is_array( $b ) ? ScalarCoercion::coerce_int( $b['Seq_in_index'] ?? null ) : 0;
+ $a_seq = is_array( $a ) ? Coercion::coerce_int( $a['Seq_in_index'] ?? null ) : 0;
+ $b_seq = is_array( $b ) ? Coercion::coerce_int( $b['Seq_in_index'] ?? null ) : 0;
return $a_seq <=> $b_seq;
}
@@ -535,7 +569,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
$columns = array();
foreach ( $rows as $row ) {
if ( is_array( $row ) ) {
- $columns[] = ScalarCoercion::coerce_string( $row['Column_name'] ?? null );
+ $columns[] = Coercion::coerce_string( $row['Column_name'] ?? null );
}
}
@@ -561,7 +595,7 @@ class SchemaInstallerTest extends EngineIntegrationTestCase {
foreach ( $rows as $row ) {
// Non_unique = 0 marks a UNIQUE index.
- if ( is_array( $row ) && '0' !== ScalarCoercion::coerce_string( $row['Non_unique'] ?? null ) ) {
+ if ( is_array( $row ) && '0' !== Coercion::coerce_string( $row['Non_unique'] ?? null ) ) {
return false;
}
}
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
index 85c21eff967..e02ed9779a3 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/SnapshotStoreTest.php
@@ -10,7 +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\Core\Support\Coercion;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SnapshotStore;
@@ -78,10 +78,10 @@ class SnapshotStoreTest extends EngineIntegrationTestCase {
$row = $this->snapshot_row( $id );
$this->assertNotNull( $row );
- $this->assertSame( '100', ScalarCoercion::coerce_string( $row['contract_id'] ?? null ) );
+ $this->assertSame( '100', Coercion::coerce_string( $row['contract_id'] ?? null ) );
$this->assertSame( SnapshotStore::TYPE_PLAN, $row['snapshot_type'] );
- $this->assertSame( '7', ScalarCoercion::coerce_string( $row['parent_id'] ?? null ) );
- $this->assertSame( '2', ScalarCoercion::coerce_string( $row['schema_version'] ?? null ) );
+ $this->assertSame( '7', Coercion::coerce_string( $row['parent_id'] ?? null ) );
+ $this->assertSame( '2', Coercion::coerce_string( $row['schema_version'] ?? null ) );
}
/**
@@ -109,7 +109,7 @@ class SnapshotStoreTest extends EngineIntegrationTestCase {
$row = $this->snapshot_row( $id );
$this->assertNotNull( $row );
- $this->assertSame( $payload, json_decode( ScalarCoercion::coerce_string( $row['payload'] ?? null ), true ) );
+ $this->assertSame( $payload, json_decode( Coercion::coerce_string( $row['payload'] ?? null ), true ) );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
new file mode 100644
index 00000000000..a3e210e1cdf
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Integration tests for the shared facade argument validators.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Support;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator
+ */
+class ArgumentValidatorTest extends EngineIntegrationTestCase {
+
+ /**
+ * @dataProvider provide_valid_values
+ *
+ * @param string $method Validator method.
+ * @param array<int, mixed> $args Validator arguments.
+ * @param mixed $expected Normalized value.
+ */
+ public function test_a_valid_value_is_returned_normalized( string $method, array $args, $expected ): void {
+ $this->assertSame( $expected, ArgumentValidator::$method( ...$args ) );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: array<int, mixed>, 2: mixed}>
+ */
+ public function provide_valid_values(): array {
+ return array(
+ 'currency' => array( 'validate_currency', array( 'EUR' ), 'EUR' ),
+ 'string' => array( 'validate_string', array( 'status', 'active' ), 'active' ),
+ 'nullable string' => array( 'validate_nullable_string', array( 'title', null ), null ),
+ 'nullable id as digits' => array( 'validate_nullable_id', array( 'order_id', '42' ), 42 ),
+ 'nullable date object' => array(
+ 'validate_nullable_date',
+ array( 'start_gmt', new DateTimeImmutable( '2026-01-01 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) ),
+ '2026-01-01 02:00:00',
+ ),
+ 'money' => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
+ 'money null is zero' => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
+ 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
+ );
+ }
+
+ /**
+ * @dataProvider provide_invalid_values
+ *
+ * @param string $method Validator method.
+ * @param array<int, mixed> $args Validator arguments.
+ * @param string $message Expected exception message.
+ */
+ public function test_an_invalid_value_is_rejected_naming_the_field( string $method, array $args, string $message ): void {
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage( $message );
+
+ ArgumentValidator::$method( ...$args );
+ }
+
+ /**
+ * @return array<string, array{0: string, 1: array<int, mixed>, 2: string}>
+ */
+ public function provide_invalid_values(): array {
+ return array(
+ 'currency' => array( 'validate_currency', array( 'eur' ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
+ 'string' => array( 'validate_string', array( 'status', 5 ), '"status" must be a string.' ),
+ 'nullable string' => array( 'validate_nullable_string', array( 'title', 5 ), '"title" must be null or a string.' ),
+ 'nullable id' => array( 'validate_nullable_id', array( 'order_id', 0 ), '"order_id" must be null or a positive integer.' ),
+ 'nullable date' => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), '"start_gmt" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.' ),
+ 'money' => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
+ 'list of arrays' => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
+ );
+ }
+
+ public function test_filter_known_keys_drops_unknown_keys_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( 'Acme::write' );
+ $messages = array();
+ add_action(
+ 'doing_it_wrong_run',
+ static function ( $function_name, $message ) use ( &$messages ): void {
+ $messages[] = $message;
+ },
+ 10,
+ 2
+ );
+
+ $filtered = ArgumentValidator::filter_known_keys(
+ 'Acme::write',
+ array(
+ 'known' => 1,
+ 'other' => 2,
+ ),
+ array( 'known' => true ),
+ 'item key'
+ );
+
+ $this->assertSame( array( 'known' => 1 ), $filtered );
+ $this->assertSame( array( 'Unknown item key "other" ignored.' ), $messages );
+ }
+
+ public function test_contract_items_keep_item_fields_and_drop_unknown_keys_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( 'facade' );
+
+ $rows = ArgumentValidator::validate_contract_items(
+ array(
+ array(
+ 'item_name' => 'Coffee',
+ 'price' => '1',
+ ),
+ ),
+ 'facade'
+ );
+
+ $this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $rows );
+ }
+
+ public function test_contract_addresses_reject_an_unknown_address_type(): void {
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
+
+ ArgumentValidator::validate_contract_addresses( array( 'home' => array( 'first_name' => 'Ada' ) ) );
+ }
+
+ public function test_contract_addresses_keep_address_fields_and_drop_unknown_keys_with_a_notice(): void {
+ $this->setExpectedIncorrectUsage( 'facade' );
+
+ $addresses = ArgumentValidator::validate_contract_addresses(
+ array(
+ 'billing' => array(
+ 'first_name' => 'Ada',
+ 'zip' => '1',
+ ),
+ ),
+ 'facade'
+ );
+
+ $this->assertSame( array( 'billing' => array( 'first_name' => 'Ada' ) ), $addresses );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
new file mode 100644
index 00000000000..341a9744bda
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/ContractViewTest.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Unit tests for the ContractView DTO.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api\View;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView
+ */
+class ContractViewTest extends TestCase {
+
+ /**
+ * A complete stored contract row.
+ *
+ * @return array<string, mixed>
+ */
+ private function row(): array {
+ return array(
+ 'id' => 10,
+ 'status' => 'active',
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'extension_slug' => 'acme-subs',
+ 'payment_method' => 'dummy',
+ 'payment_method_title' => 'Dummy',
+ 'payment_token_id' => 4,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ 'next_payment_gmt' => '2026-02-01 00:00:00',
+ 'last_payment_gmt' => '2026-01-01 00:00:00',
+ 'last_attempt_gmt' => '2026-01-01 00:00:01',
+ 'trial_end_gmt' => '2026-01-08 00:00:00',
+ 'end_gmt' => '2027-01-01 00:00:00',
+ 'billing_total' => '20.00',
+ 'discount_total' => '1.00',
+ 'shipping_total' => '5.00',
+ 'tax_total' => '2.50',
+ 'schedule_source' => Contract::SCHEDULE_SOURCE_GATEWAY,
+ );
+ }
+
+ public function test_every_getter_reads_the_stored_row(): void {
+ $view = ContractView::from_contract( Contract::from_storage( $this->row() ), false );
+
+ $this->assertSame( 10, $view->get_id() );
+ $this->assertSame( 'active', $view->get_status() );
+ $this->assertSame( 'acme-subs', $view->get_extension_slug() );
+ $this->assertSame( 1, $view->get_customer_id() );
+ $this->assertSame( 'USD', $view->get_currency() );
+ $this->assertSame( 2, $view->get_selling_plan_id() );
+ $this->assertSame( 3, $view->get_origin_order_id() );
+ $this->assertSame( 'dummy', $view->get_payment_method() );
+ $this->assertSame( 'Dummy', $view->get_payment_method_title() );
+ $this->assertSame( 4, $view->get_payment_token_id() );
+ $this->assertSame( '2026-01-01 00:00:00', $view->get_start_gmt() );
+ $this->assertSame( '2026-02-01 00:00:00', $view->get_next_payment_gmt() );
+ $this->assertSame( '2026-01-01 00:00:00', $view->get_last_payment_gmt() );
+ $this->assertSame( '2026-01-01 00:00:01', $view->get_last_attempt_gmt() );
+ $this->assertSame( '2026-01-08 00:00:00', $view->get_trial_end_gmt() );
+ $this->assertSame( '2027-01-01 00:00:00', $view->get_end_gmt() );
+ $this->assertSame( '20.00000000', $view->get_billing_total() );
+ $this->assertSame( '1.00000000', $view->get_discount_total() );
+ $this->assertSame( '5.00000000', $view->get_shipping_total() );
+ $this->assertSame( '2.50000000', $view->get_tax_total() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $view->get_schedule_source() );
+ }
+
+ public function test_optional_fields_read_as_null(): void {
+ $view = ContractView::from_contract( Contract::from_storage( array( 'id' => 5 ) ), true );
+
+ $this->assertNull( $view->get_extension_slug() );
+ $this->assertNull( $view->get_customer_id() );
+ $this->assertNull( $view->get_currency() );
+ $this->assertNull( $view->get_selling_plan_id() );
+ $this->assertNull( $view->get_payment_method() );
+ $this->assertNull( $view->get_start_gmt() );
+ }
+
+ public function test_children_are_null_when_not_loaded(): void {
+ $view = ContractView::from_contract( Contract::from_storage( $this->row(), null, array( array( 'item_name' => 'Tea' ) ) ), false );
+
+ $this->assertNull( $view->get_items() );
+ $this->assertNull( $view->get_addresses() );
+ }
+
+ public function test_children_are_projected_onto_the_write_shape(): void {
+ $items = array(
+ array(
+ 'id' => '7',
+ 'contract_id' => '42',
+ 'item_name' => 'Tea',
+ 'item_type' => 'line_item',
+ 'product_id' => '12',
+ 'variation_id' => null,
+ 'quantity' => '2.0000',
+ 'subtotal' => '10.00000000',
+ 'total' => '9.00000000',
+ 'taxes' => '{"total":{"1":"0.90"}}',
+ ),
+ );
+ $addresses = array(
+ 'billing' => array(
+ 'id' => '3',
+ 'contract_id' => '42',
+ 'address_type' => 'billing',
+ 'city' => 'Lisbon',
+ ),
+ );
+
+ $loaded = ContractView::from_contract( Contract::from_storage( $this->row(), null, $items, $addresses ), true );
+ $empty = ContractView::from_contract( Contract::from_storage( $this->row() ), true );
+
+ $this->assertSame(
+ array(
+ array(
+ 'item_name' => 'Tea',
+ 'item_type' => 'line_item',
+ 'product_id' => 12,
+ 'variation_id' => null,
+ 'quantity' => '2.0000',
+ 'subtotal' => '10.00000000',
+ 'total' => '9.00000000',
+ 'taxes' => array( 'total' => array( 1 => '0.90' ) ),
+ ),
+ ),
+ $loaded->get_items()
+ );
+ $addresses = $loaded->get_addresses();
+ $this->assertIsArray( $addresses );
+ $this->assertSame( array( 'billing' ), array_keys( $addresses ) );
+ $this->assertSame( Contract::ADDRESS_FIELDS, array_keys( $addresses['billing'] ) );
+ $this->assertSame( 'Lisbon', $addresses['billing']['city'] );
+ $this->assertNull( $addresses['billing']['phone'] );
+ $this->assertSame( array(), $empty->get_items() );
+ $this->assertSame( array(), $empty->get_addresses() );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
new file mode 100644
index 00000000000..f4279620fd9
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Api/View/CycleViewTest.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Unit tests for the CycleView DTO.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Api\View;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView
+ */
+class CycleViewTest extends TestCase {
+
+ public function test_every_getter_reads_the_stored_row(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 9,
+ 'contract_id' => 10,
+ 'kind' => Cycle::KIND_BILLING,
+ 'sequence_no' => 2,
+ 'count' => 2,
+ 'status' => 'billed',
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ 'order_id' => 77,
+ )
+ );
+
+ $view = CycleView::from_cycle( $cycle );
+
+ $this->assertSame( 9, $view->get_id() );
+ $this->assertSame( 10, $view->get_contract_id() );
+ $this->assertSame( Cycle::KIND_BILLING, $view->get_kind() );
+ $this->assertSame( 2, $view->get_sequence_no() );
+ $this->assertSame( 2, $view->get_count() );
+ $this->assertSame( 'billed', $view->get_status() );
+ $this->assertSame( '2026-02-01 00:00:00', $view->get_starts_at_gmt() );
+ $this->assertSame( '2026-03-01 00:00:00', $view->get_ends_at_gmt() );
+ $this->assertSame( '20.00000000', $view->get_expected_total() );
+ $this->assertSame( 'USD', $view->get_currency() );
+ $this->assertSame( 77, $view->get_order_id() );
+ }
+
+ public function test_a_non_counting_cycle_without_an_order_reads_null(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 1,
+ 'contract_id' => 2,
+ 'sequence_no' => 1,
+ 'count' => null,
+ 'status' => 'pending',
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'currency' => 'USD',
+ )
+ );
+
+ $view = CycleView::from_cycle( $cycle );
+
+ $this->assertNull( $view->get_count() );
+ $this->assertNull( $view->get_order_id() );
+ $this->assertSame( 'pending', $view->get_status() );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
index 7082643bf13..c953d4541ee 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractStatusTest.php
@@ -28,13 +28,17 @@ class ContractStatusTest extends TestCase {
$this->assertFalse( ContractStatus::is_registered( 'nonsense' ) );
}
- public function test_defaults_lists_the_five_engine_slugs(): void {
+ public function test_defaults_lists_the_six_engine_slugs(): void {
$this->assertSame(
- array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+ array( 'draft', 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
ContractStatus::get_defaults()
);
}
+ public function test_draft_is_a_registered_default(): void {
+ $this->assertTrue( ContractStatus::is_registered( 'draft' ) );
+ }
+
public function test_all_equals_the_defaults_with_nothing_registered(): void {
$this->assertSame( ContractStatus::get_defaults(), ContractStatus::get_all() );
}
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 d04957c122f..98e56aab395 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
@@ -66,6 +66,7 @@ class ContractTest extends TestCase {
private function make_contract(): Contract {
return Contract::create(
array(
+ 'status' => ContractStatus::ACTIVE,
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -78,9 +79,152 @@ class ContractTest extends TestCase {
}
/**
- * @testdox create() builds an active contract from its identity and live config.
+ * @testdox create() with no attributes yields a draft with no customer, currency, plan or start.
*/
- public function test_create_builds_an_active_contract(): void {
+ public function test_create_with_no_args_is_an_empty_draft(): void {
+ $contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+ $this->assertSame( ContractStatus::DRAFT, $contract->get_status() );
+ $this->assertNull( $contract->get_customer_id() );
+ $this->assertNull( $contract->get_currency() );
+ $this->assertNull( $contract->get_selling_plan_id() );
+ $this->assertNull( $contract->get_start_gmt() );
+
+ $row = $contract->to_storage();
+ $this->assertNull( $row['customer_id'] );
+ $this->assertNull( $row['currency'] );
+ $this->assertNull( $row['selling_plan_id'] );
+ $this->assertNull( $row['start_gmt'] );
+ }
+
+ /**
+ * @testdox create() rejects a non-zero total without a currency.
+ */
+ public function test_create_rejects_a_non_zero_total_without_a_currency(): void {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( 'money totals require a currency' );
+
+ Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'billing_total' => '10',
+ )
+ );
+ }
+
+ /**
+ * @testdox Zero totals need no currency.
+ */
+ public function test_zero_totals_need_no_currency(): void {
+ $contract = Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'billing_total' => '0',
+ )
+ );
+
+ $contract->assert_money_has_currency();
+ $this->assertNull( $contract->get_currency() );
+ }
+
+ /**
+ * @testdox Clearing the currency while a total is non-zero is refused.
+ */
+ public function test_clearing_the_currency_with_a_non_zero_total_is_refused(): void {
+ $contract = Contract::create(
+ array(
+ 'extension_slug' => 'acme-subs',
+ 'currency' => 'USD',
+ 'billing_total' => '10',
+ )
+ );
+ $contract->set_currency( null );
+
+ $this->expectException( DomainException::class );
+ $contract->assert_money_has_currency();
+ }
+
+ /**
+ * @testdox create() requires an extension_slug.
+ */
+ public function test_create_requires_an_extension_slug(): void {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( 'Contract: extension_slug is required' );
+
+ Contract::create( array() );
+ }
+
+ /**
+ * @testdox create() rejects an empty extension_slug.
+ */
+ public function test_create_rejects_an_empty_extension_slug(): void {
+ $this->expectException( DomainException::class );
+
+ Contract::create( array( 'extension_slug' => '' ) );
+ }
+
+ /**
+ * @testdox from_storage() hydrates a row with no extension_slug.
+ */
+ public function test_from_storage_allows_a_null_extension_slug(): void {
+ $contract = Contract::from_storage( array( 'id' => 1 ) );
+
+ $this->assertNull( $contract->get_extension_slug() );
+ }
+
+ /**
+ * @testdox The facade setters round-trip their values.
+ */
+ public function test_setters_round_trip(): void {
+ $contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+ $contract->set_customer_id( 7 );
+ $contract->set_currency( 'EUR' );
+ $contract->set_selling_plan_id( 8 );
+ $contract->set_origin_order_id( 9 );
+ $contract->set_start_gmt( '2026-03-01 00:00:00' );
+ $contract->set_schedule_source( Contract::SCHEDULE_SOURCE_GATEWAY );
+ $contract->set_items( array( array( 'item_name' => 'Coffee' ), 'skipped' ) );
+ $contract->set_addresses( array( Contract::ADDRESS_BILLING => array( 'city' => 'Lisbon' ) ) );
+
+ $this->assertSame( 7, $contract->get_customer_id() );
+ $this->assertSame( 'EUR', $contract->get_currency() );
+ $this->assertSame( 8, $contract->get_selling_plan_id() );
+ $this->assertSame( 9, $contract->get_origin_order_id() );
+ $this->assertSame( '2026-03-01 00:00:00', $contract->get_start_gmt() );
+ $this->assertSame( Contract::SCHEDULE_SOURCE_GATEWAY, $contract->get_schedule_source() );
+ $this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $contract->get_items() );
+ $this->assertSame( array( 'billing' => array( 'city' => 'Lisbon' ) ), $contract->get_addresses() );
+
+ $contract->set_customer_id( null );
+ $contract->set_currency( null );
+ $contract->set_selling_plan_id( null );
+ $contract->set_origin_order_id( null );
+ $contract->set_start_gmt( null );
+
+ $this->assertNull( $contract->get_customer_id() );
+ $this->assertNull( $contract->get_currency() );
+ $this->assertNull( $contract->get_selling_plan_id() );
+ $this->assertNull( $contract->get_origin_order_id() );
+ $this->assertNull( $contract->get_start_gmt() );
+ }
+
+ /**
+ * @testdox set_schedule_source() refuses an unknown source.
+ */
+ public function test_set_schedule_source_rejects_an_unknown_source(): void {
+ $contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );
+
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( 'Contract: invalid schedule source "bogus".' );
+
+ $contract->set_schedule_source( 'bogus' );
+ }
+
+ /**
+ * @testdox create() builds a contract from its identity and live config.
+ */
+ public function test_create_builds_a_contract_from_its_identity(): void {
$contract = $this->make_contract();
$this->assertNull( $contract->get_id() );
@@ -101,6 +245,7 @@ class ContractTest extends TestCase {
public function test_create_defaults_live_config(): void {
$contract = Contract::create(
array(
+ 'extension_slug' => 'acme-subs',
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -109,7 +254,6 @@ class ContractTest extends TestCase {
);
$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() );
@@ -129,6 +273,7 @@ class ContractTest extends TestCase {
public function test_create_normalizes_live_totals(): void {
$contract = Contract::create(
array(
+ 'extension_slug' => 'acme-subs',
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -153,6 +298,7 @@ class ContractTest extends TestCase {
public function test_create_allows_a_null_origin_order_id(): void {
$contract = Contract::create(
array(
+ 'extension_slug' => 'acme-subs',
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -171,6 +317,7 @@ class ContractTest extends TestCase {
Contract::create(
array(
+ 'extension_slug' => 'acme-subs',
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -189,6 +336,7 @@ class ContractTest extends TestCase {
Contract::create(
array(
+ 'extension_slug' => 'acme-subs',
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -308,20 +456,18 @@ class ContractTest extends TestCase {
}
/**
- * @testdox from_storage() hydrates the plan snapshot, items, addresses, and meta children.
+ * @testdox from_storage() hydrates the plan snapshot, items, and addresses.
*/
public function test_from_storage_hydrates_children(): void {
$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
$items = array( array( 'product_id' => 42 ) );
$addresses = array( 'billing' => array( 'first_name' => 'Ada' ) );
- $meta = array( 'flag' => 'on' );
- $contract = Contract::from_storage( $this->valid_row(), $snapshot, $items, $addresses, $meta );
+ $contract = Contract::from_storage( $this->valid_row(), $snapshot, $items, $addresses );
$this->assertSame( $snapshot, $contract->get_plan_snapshot() );
$this->assertSame( $items, $contract->get_items() );
$this->assertSame( $addresses, $contract->get_addresses() );
- $this->assertSame( $meta, $contract->get_meta() );
}
/**
@@ -466,6 +612,7 @@ class ContractTest extends TestCase {
$created = Contract::create(
array(
+ 'extension_slug' => 'acme-subs',
'customer_id' => 1,
'currency' => 'USD',
'selling_plan_id' => 2,
@@ -495,26 +642,4 @@ class ContractTest extends TestCase {
$this->assertSame( 'legacy-paused', $contract->to_storage()['status'] );
}
-
- /**
- * @testdox set_meta() adds, overwrites, and (with null) removes a key.
- */
- public function test_set_meta_adds_overwrites_and_removes_a_key(): void {
- $contract = Contract::from_storage( $this->valid_row(), null, array(), array(), array( 'keep' => 'me' ) );
-
- $contract->set_meta( 'k', 'v' );
- $this->assertSame(
- array(
- 'keep' => 'me',
- 'k' => 'v',
- ),
- $contract->get_meta()
- );
-
- $contract->set_meta( 'k', 'w' );
- $this->assertSame( 'w', $contract->get_meta()['k'] );
-
- $contract->set_meta( 'k', null );
- $this->assertSame( array( 'keep' => 'me' ), $contract->get_meta() );
- }
}
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
index 8f6480c7d73..517f2c83e06 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -10,6 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
use DomainException;
+use LogicException;
use PHPUnit\Framework\TestCase;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
@@ -125,6 +126,20 @@ class CycleTest extends TestCase {
$this->make_pending( array( 'sequence_no' => 0 ) );
}
+ public function test_create_requires_a_start_and_an_end(): void {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( 'starts_at_gmt and ends_at_gmt are required' );
+
+ $this->make_pending( array( 'ends_at_gmt' => null ) );
+ }
+
+ public function test_create_requires_a_currency(): void {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( 'currency is required' );
+
+ $this->make_pending( array( 'currency' => null ) );
+ }
+
public function test_create_rejects_a_non_positive_count(): void {
$this->expectException( DomainException::class );
@@ -442,6 +457,29 @@ class CycleTest extends TestCase {
$cycle->set_items_snapshot_id( 99 );
}
+ public function test_from_storage_keeps_stored_values_that_create_would_refuse(): void {
+ $cycle = Cycle::from_storage(
+ array(
+ 'id' => 5,
+ 'contract_id' => 0,
+ 'sequence_no' => 0,
+ 'count' => 0,
+ 'kind' => '',
+ 'status' => 'legacy-x',
+ 'starts_at_gmt' => '2026-03-01 00:00:00',
+ 'ends_at_gmt' => '2026-04-01 00:00:00',
+ 'expected_total' => '20.00',
+ 'currency' => 'USD',
+ )
+ );
+
+ $row = $cycle->to_storage();
+ $this->assertSame( 0, $row['contract_id'] );
+ $this->assertSame( 0, $row['sequence_no'] );
+ $this->assertSame( 0, $row['count'] );
+ $this->assertSame( '', $row['kind'] );
+ }
+
public function test_from_storage_hydrates_a_non_counting_cycle(): void {
$cycle = Cycle::from_storage(
array(
@@ -495,19 +533,58 @@ class CycleTest extends TestCase {
$this->assertSame( 'pending', $row['status'] );
}
- public function test_sequence_no_can_be_reassigned_after_construction(): void {
- $cycle = $this->make_pending( array( 'sequence_no' => 1 ) );
+ /**
+ * Build a cycle without `sequence_no` or `count`.
+ */
+ private function make_unpositioned(): Cycle {
+ return Cycle::create(
+ array(
+ 'contract_id' => 7,
+ 'starts_at_gmt' => '2026-02-01 00:00:00',
+ 'ends_at_gmt' => '2026-03-01 00:00:00',
+ 'currency' => 'USD',
+ )
+ );
+ }
+
+ public function test_create_without_a_sequence_no_leaves_it_to_the_append(): void {
+ $this->assertTrue( $this->make_unpositioned()->awaits_sequence_no() );
+ $this->assertFalse( $this->make_pending()->awaits_sequence_no() );
+ }
+
+ public function test_a_null_sequence_no_is_unassigned_like_an_absent_one(): void {
+ $this->assertTrue( $this->make_pending( array( 'sequence_no' => null ) )->awaits_sequence_no() );
+ }
- $cycle->set_sequence_no( 4 );
+ public function test_create_without_a_count_is_non_counting(): void {
+ $this->assertNull( $this->make_unpositioned()->get_count() );
+ }
+
+ public function test_an_unassigned_sequence_no_cannot_be_read(): void {
+ $this->expectException( LogicException::class );
+
+ $this->make_unpositioned()->get_sequence_no();
+ }
+
+ public function test_the_sequence_no_can_be_assigned_once(): void {
+ $cycle = $this->make_unpositioned();
+
+ $cycle->assign_sequence_no( 4 );
$this->assertSame( 4, $cycle->get_sequence_no() );
+ $this->assertFalse( $cycle->awaits_sequence_no() );
}
- public function test_set_sequence_no_rejects_a_non_positive_value(): void {
- $cycle = $this->make_pending();
+ public function test_assign_sequence_no_rejects_a_non_positive_value(): void {
+ $this->expectException( DomainException::class );
+ $this->make_unpositioned()->assign_sequence_no( 0 );
+ }
+
+ public function test_an_assigned_sequence_no_cannot_be_reassigned(): void {
$this->expectException( DomainException::class );
- $cycle->set_sequence_no( 0 );
+
+ $this->make_pending()->assign_sequence_no( 2 );
}
public function test_id_can_be_stamped_after_persistence(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
index ac3a19aa865..b17d1c8e9e0 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/StatusRegistryTest.php
@@ -27,7 +27,7 @@ class StatusRegistryTest extends TestCase {
public function test_all_returns_exactly_the_engine_defaults_with_nothing_registered(): void {
$this->assertSame(
- array( 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
+ array( 'draft', 'active', 'on-hold', 'pending-cancellation', 'cancelled', 'expired' ),
StatusRegistry::get_all( StatusRegistry::KIND_CONTRACT )
);
$this->assertSame(
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
new file mode 100644
index 00000000000..aefca28998b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/CoercionTest.php
@@ -0,0 +1,111 @@
+<?php
+/**
+ * Unit tests for Coercion's array coercions.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion
+ */
+class CoercionTest extends TestCase {
+
+ public function test_coerce_string_keyed_casts_keys_to_strings_and_keeps_values(): void {
+ $result = Coercion::coerce_string_keyed(
+ array(
+ 0 => 'zero',
+ 'a' => array( 1, 2 ),
+ 7 => null,
+ )
+ );
+
+ $this->assertSame(
+ array(
+ '0' => 'zero',
+ 'a' => array( 1, 2 ),
+ '7' => null,
+ ),
+ $result
+ );
+ }
+
+ public function test_coerce_string_keyed_keeps_empty_array(): void {
+ $this->assertSame( array(), Coercion::coerce_string_keyed( array() ) );
+ }
+
+ /**
+ * @dataProvider provide_non_arrays
+ *
+ * @param mixed $value Non-array input.
+ */
+ public function test_coerce_list_of_arrays_returns_empty_for_non_array( $value ): void {
+ $this->assertSame( array(), Coercion::coerce_list_of_arrays( $value ) );
+ }
+
+ public function test_coerce_list_of_arrays_skips_non_array_rows_and_reindexes(): void {
+ $result = Coercion::coerce_list_of_arrays(
+ array(
+ 'x' => array( 'sku' => 'A' ),
+ 5 => 'not a row',
+ 9 => array( 0 => 'b' ),
+ 10 => null,
+ )
+ );
+
+ $this->assertSame(
+ array(
+ array( 'sku' => 'A' ),
+ array( '0' => 'b' ),
+ ),
+ $result
+ );
+ }
+
+ /**
+ * @dataProvider provide_non_arrays
+ *
+ * @param mixed $value Non-array input.
+ */
+ public function test_coerce_map_of_arrays_returns_empty_for_non_array( $value ): void {
+ $this->assertSame( array(), Coercion::coerce_map_of_arrays( $value ) );
+ }
+
+ public function test_coerce_map_of_arrays_keeps_keys_and_skips_non_array_entries(): void {
+ $result = Coercion::coerce_map_of_arrays(
+ array(
+ 'billing' => array( 'city' => 'Lisbon' ),
+ 'shipping' => 'not an entry',
+ 3 => array( 1 => 'x' ),
+ )
+ );
+
+ $this->assertSame(
+ array(
+ 'billing' => array( 'city' => 'Lisbon' ),
+ '3' => array( '1' => 'x' ),
+ ),
+ $result
+ );
+ }
+
+ /**
+ * Non-array inputs.
+ *
+ * @return array<string, array{0: mixed}>
+ */
+ public function provide_non_arrays(): array {
+ return array(
+ 'null' => array( null ),
+ 'string' => array( 'items' ),
+ 'int' => array( 3 ),
+ 'object' => array( new \stdClass() ),
+ );
+ }
+}