Commit 8dbd0dcf1ae for woocommerce
commit 8dbd0dcf1aeef2acd7b2e0eb24a05bb952281da4
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Wed Oct 21 17:57:36 2026 +0200
Subscriptions engine: remove the BillingPolicy and DeliveryPolicy value objects (#70095)
Remove subscriptions engine billing and delivery policy value objects
- Remove Core\ValueObject\BillingPolicy and DeliveryPolicy, which no engine code consumes
- Remove PlanSnapshot::read_billing_policy(); snapshots still return the captured billing payload
- Compute the integration fixture's first renewal directly and drop references to the removed classes
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-drop-policy-value-objects b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-drop-policy-value-objects
new file mode 100644
index 00000000000..2ff067e69d3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-drop-policy-value-objects
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Remove the BillingPolicy and DeliveryPolicy value objects and PlanSnapshot::read_billing_policy() from the unreleased subscriptions engine; no changelog entry needed.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
index 6f6f4e992a2..3c3f1999f0d 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -10,8 +10,7 @@
* reaches a plan of another one (authorization is the caller's concern). Reads return
* read-only {@see PlanView}s. The engine opens no transaction and keeps no cache.
*
- * Plan policies are stored as given; the engine does not read them. An extension may
- * parse a `billing_policy` with {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy::from_array()}.
+ * Plan policies are stored as given; the engine does not read them.
*
* @package Automattic\WooCommerce\SubscriptionsEngine\Api
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
deleted file mode 100644
index 5ee5a6f9cfc..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ /dev/null
@@ -1,472 +0,0 @@
-<?php
-/**
- * BillingPolicy - an optional parser for a billing cadence and trial payload.
- *
- * An extension may use {@see self::from_array()} to parse its plan billing arrays and
- * compute due moments from them. The engine stores plan policies opaquely and does not
- * read them itself. A policy always has a usable cadence and trial: construction refuses an
- * unknown period or trial unit and a non-positive interval or trial length. The array shape it parses:
- * {
- * period: 'day' | 'week' | 'month' | 'year',
- * interval: int,
- * min_cycles: ?int,
- * max_cycles: ?int,
- * trial_duration: { length: int, unit: 'day'|'week'|'month'|'year' } | null
- * }
- *
- * Trial is a native field: the first cycle's billing date is delayed by the
- * trial at contract creation rather than modelled as a discount.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * BillingPolicy value object.
- *
- * Immutable. Construct via {@see self::from_array()} when hydrating from a
- * stored row, or via the constructor when building one in code.
- */
-final class BillingPolicy {
-
- /**
- * Period unit: 'day' | 'week' | 'month' | 'year'.
- *
- * @var string
- */
- private $period;
-
- /**
- * Period count (e.g. 2 with 'week' = every 2 weeks).
- *
- * @var int
- */
- private $interval;
-
- /**
- * Minimum cycles before cancellation is allowed; null if unbounded below.
- *
- * @var int|null
- */
- private $min_cycles;
-
- /**
- * Total cycles before the contract ends; null if open-ended.
- *
- * @var int|null
- */
- private $max_cycles;
-
- /**
- * Native trial; null if no trial.
- *
- * @var array{length: int, unit: string}|null
- */
- private $trial_duration;
-
- /**
- * Build a billing policy. Every invalid value is reported in one exception.
- *
- * @param string $period One of 'day' | 'week' | 'month' | 'year'.
- * @param int $interval Period count.
- * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
- * @param int|null $max_cycles Total cycles before the contract ends.
- * @param array{length: int, unit: string}|null $trial_duration Native trial; null if none.
- * @throws InvalidInputException If the period is unknown, the interval is not positive, the cycle bounds or the trial are invalid.
- */
- public function __construct( string $period, int $interval, ?int $min_cycles, ?int $max_cycles, ?array $trial_duration ) {
- self::throw_found_errors(
- array_merge(
- array(
- self::get_period_error( $period ),
- self::get_interval_error( $interval ),
- ),
- self::get_cycle_errors( $min_cycles, $max_cycles ),
- self::get_trial_errors( $trial_duration )
- )
- );
-
- $this->period = $period;
- $this->interval = $interval;
- $this->min_cycles = $min_cycles;
- $this->max_cycles = $max_cycles;
- $this->trial_duration = null === $trial_duration ? null : array(
- 'length' => (int) $trial_duration['length'],
- 'unit' => (string) $trial_duration['unit'],
- );
- }
-
- /**
- * Hydrate from the JSON-decoded `billing_policy` column shape.
- *
- * Missing nullable keys default to null. `period` and `interval` are required, and
- * must form a usable cadence (see the constructor). Every invalid value is reported
- * in one exception.
- *
- * @param array<string, mixed> $data Decoded billing_policy row.
- * @throws InvalidInputException If the data is not valid.
- */
- public static function from_array( array $data ): self {
- $min_cycles = Coercion::coerce_nullable_int( $data['min_cycles'] ?? null );
- $max_cycles = Coercion::coerce_nullable_int( $data['max_cycles'] ?? null );
- $trial = $data['trial_duration'] ?? null;
-
- self::throw_found_errors(
- array_merge(
- array(
- array_key_exists( 'period', $data )
- ? self::get_period_error( $data['period'] )
- : new InvalidInputException( ValidationErrorCode::REQUIRED, 'period', 'BillingPolicy: period is required, but not supplied.' ),
- array_key_exists( 'interval', $data )
- ? self::get_interval_error( $data['interval'] )
- : new InvalidInputException( ValidationErrorCode::REQUIRED, 'interval', 'BillingPolicy: interval is required, but not supplied.' ),
- ),
- self::get_cycle_errors( $min_cycles, $max_cycles ),
- array( self::get_trial_duration_error( $trial ) ),
- is_array( $trial ) ? self::get_trial_errors( $trial ) : array()
- )
- );
-
- return new self(
- Coercion::coerce_string( $data['period'] ),
- Coercion::coerce_int( $data['interval'] ),
- $min_cycles,
- $max_cycles,
- is_array( $trial ) ? array(
- 'length' => Coercion::coerce_int( $trial['length'] ?? null ),
- 'unit' => Coercion::coerce_string( $trial['unit'] ?? null ),
- ) : null
- );
- }
-
- /**
- * Period unit: 'day' | 'week' | 'month' | 'year'.
- */
- public function get_period(): string {
- return $this->period;
- }
-
- /**
- * Period count. Together with `period` defines cadence.
- */
- public function get_interval(): int {
- return $this->interval;
- }
-
- /**
- * Minimum cycles before cancellation is allowed; null if unbounded below.
- */
- public function get_min_cycles(): ?int {
- return $this->min_cycles;
- }
-
- /**
- * Total cycles before the contract ends; null if open-ended.
- */
- public function get_max_cycles(): ?int {
- return $this->max_cycles;
- }
-
- /**
- * Native trial duration, or null if no trial.
- *
- * @return array{length: int, unit: string}|null
- */
- public function get_trial_duration(): ?array {
- return $this->trial_duration;
- }
-
- /**
- * Compute the next renewal moment by adding the policy's cadence to `$anchor`.
- *
- * Trial duration is not applied here. The result is normalized to UTC and
- * period semantics are calendar-aware (matching DateTimeImmutable::modify()):
- * adding 1 month to 2026-01-31 yields 2026-03-03, not 2026-02-31.
- *
- * @param DateTimeImmutable $anchor The moment the next cycle is computed from.
- * @return DateTimeImmutable The next renewal moment in UTC.
- */
- public function compute_next_renewal_from( DateTimeImmutable $anchor ): DateTimeImmutable {
- $utc = $anchor->setTimezone( new DateTimeZone( 'UTC' ) );
-
- return $utc->modify( sprintf( '+%d %s', $this->interval, $this->period ) );
- }
-
- /**
- * Compute the first renewal moment for a freshly-created contract.
- *
- * Honours the policy's native trial: when set, the first cycle's billing
- * date is the end of the trial. With no trial this delegates to
- * {@see self::compute_next_renewal_from()} so there is one cadence-math path.
- *
- * @param DateTimeImmutable $contract_start Moment the contract was created.
- * @return DateTimeImmutable The first renewal moment in UTC.
- */
- public function compute_first_renewal_from( DateTimeImmutable $contract_start ): DateTimeImmutable {
- if ( null === $this->trial_duration ) {
- return $this->compute_next_renewal_from( $contract_start );
- }
-
- return $contract_start
- ->setTimezone( new DateTimeZone( 'UTC' ) )
- ->modify( sprintf( '+%d %s', $this->trial_duration['length'], $this->trial_duration['unit'] ) );
- }
-
- /**
- * Serialize back to the JSON column shape. Lossless round-trip with from_array().
- *
- * @return array<string, mixed>
- */
- public function to_array(): array {
- return array(
- 'period' => $this->period,
- 'interval' => $this->interval,
- 'min_cycles' => $this->min_cycles,
- 'max_cycles' => $this->max_cycles,
- 'trial_duration' => $this->trial_duration,
- );
- }
-
- /**
- * The error for a unit that is not one of day/week/month/year, or null.
- *
- * @param string $unit The raw unit.
- * @param string $field Field the unit came from, in dot notation.
- */
- private static function get_unit_error( string $unit, string $field ): ?InvalidInputException {
- $allowed = array( 'day', 'week', 'month', 'year' );
- if ( ! in_array( $unit, $allowed, true ) ) {
- return new InvalidInputException(
- ValidationErrorCode::NOT_IN_ENUM,
- $field,
- sprintf( 'BillingPolicy: invalid %s "%s".', $field, $unit ),
- array( 'allowed' => $allowed )
- );
- }
-
- return null;
- }
-
- /**
- * Throw the non-null errors at once; do nothing when there are none.
- *
- * @param array<int, InvalidInputException|null> $errors Errors, null where a check passed.
- * @throws InvalidInputException If an error is present.
- */
- private static function throw_found_errors( array $errors ): void {
- $errors = array_values( array_filter( $errors ) );
- if ( array() !== $errors ) {
- throw InvalidInputException::from_errors( $errors );
- }
- }
-
- /**
- * The error for a period that is not a string or not a known unit, or null.
- *
- * @param mixed $period Candidate period.
- */
- private static function get_period_error( $period ): ?InvalidInputException {
- if ( ! is_string( $period ) ) {
- return new InvalidInputException(
- ValidationErrorCode::INVALID_TYPE,
- 'period',
- 'BillingPolicy: period must be a string, got ' . gettype( $period ) . '.',
- array( 'type' => 'string' )
- );
- }
-
- return self::get_unit_error( $period, 'period' );
- }
-
- /**
- * The error for an interval that is not a positive integer, or null.
- *
- * @param mixed $interval Candidate interval.
- */
- private static function get_interval_error( $interval ): ?InvalidInputException {
- if ( ! is_int( $interval ) ) {
- return new InvalidInputException(
- ValidationErrorCode::INVALID_TYPE,
- 'interval',
- 'BillingPolicy: interval must be an integer, got ' . gettype( $interval ) . '.',
- array( 'type' => 'integer' )
- );
- }
- if ( $interval <= 0 ) {
- return new InvalidInputException(
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'interval',
- sprintf( 'BillingPolicy: interval must be positive, got %d.', $interval ),
- array( 'min' => 1 )
- );
- }
-
- return null;
- }
-
- /**
- * The cycle bound errors: each bound's own, then `min_cycles` above `max_cycles` only
- * when both passed theirs.
- *
- * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
- * @param int|null $max_cycles Total cycles before the contract ends.
- * @return array<int, InvalidInputException|null>
- */
- private static function get_cycle_errors( ?int $min_cycles, ?int $max_cycles ): array {
- $min_error = self::get_min_cycles_error( $min_cycles );
- $max_error = self::get_max_cycles_error( $max_cycles );
- if ( null !== $min_error || null !== $max_error ) {
- return array( $min_error, $max_error );
- }
-
- return array( self::get_cycle_bounds_error( $min_cycles, $max_cycles ) );
- }
-
- /**
- * The error for a negative `min_cycles`, or null.
- *
- * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
- */
- private static function get_min_cycles_error( ?int $min_cycles ): ?InvalidInputException {
- if ( null !== $min_cycles && $min_cycles < 0 ) {
- return new InvalidInputException(
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'min_cycles',
- sprintf( 'BillingPolicy: min_cycles must be 0 or greater, got %d.', $min_cycles ),
- array( 'min' => 0 )
- );
- }
-
- return null;
- }
-
- /**
- * The error for a negative `max_cycles`, or null.
- *
- * @param int|null $max_cycles Total cycles before the contract ends.
- */
- private static function get_max_cycles_error( ?int $max_cycles ): ?InvalidInputException {
- if ( null !== $max_cycles && $max_cycles < 0 ) {
- return new InvalidInputException(
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'max_cycles',
- sprintf( 'BillingPolicy: max_cycles must be 0 or greater, got %d.', $max_cycles ),
- array( 'min' => 0 )
- );
- }
-
- return null;
- }
-
- /**
- * The error for `min_cycles` above `max_cycles`, or null.
- *
- * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
- * @param int|null $max_cycles Total cycles before the contract ends.
- */
- private static function get_cycle_bounds_error( ?int $min_cycles, ?int $max_cycles ): ?InvalidInputException {
- if ( null !== $min_cycles && null !== $max_cycles && $min_cycles > $max_cycles ) {
- return new InvalidInputException(
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'min_cycles',
- sprintf( 'BillingPolicy: min_cycles cannot exceed max_cycles, got %d and %d.', $min_cycles, $max_cycles ),
- array( 'max' => $max_cycles )
- );
- }
-
- return null;
- }
-
- /**
- * The error for a trial duration that is neither null nor an array, or null.
- *
- * @param mixed $trial_duration Candidate trial duration.
- */
- private static function get_trial_duration_error( $trial_duration ): ?InvalidInputException {
- if ( null !== $trial_duration && ! is_array( $trial_duration ) ) {
- return new InvalidInputException(
- ValidationErrorCode::INVALID_TYPE,
- 'trial_duration',
- sprintf( 'BillingPolicy: trial_duration must be null or an array, got %s.', gettype( $trial_duration ) ),
- array( 'type' => 'object' )
- );
- }
-
- return null;
- }
-
- /**
- * The trial errors: the length's, then the unit's. None for no trial.
- *
- * @param array<array-key, mixed>|null $trial_duration Trial duration.
- * @return array<int, InvalidInputException|null>
- */
- private static function get_trial_errors( ?array $trial_duration ): array {
- if ( null === $trial_duration ) {
- return array();
- }
-
- return array( self::get_trial_length_error( $trial_duration ), self::get_trial_unit_error( $trial_duration ) );
- }
-
- /**
- * The error for a missing, non-integer or non-positive trial length, or null.
- *
- * @param array<array-key, mixed> $trial_duration Trial duration.
- */
- private static function get_trial_length_error( array $trial_duration ): ?InvalidInputException {
- if ( ! array_key_exists( 'length', $trial_duration ) ) {
- return new InvalidInputException( ValidationErrorCode::REQUIRED, 'trial_duration.length', 'BillingPolicy: trial_duration.length is required.' );
- }
- if ( ! is_int( $trial_duration['length'] ) ) {
- return new InvalidInputException(
- ValidationErrorCode::INVALID_TYPE,
- 'trial_duration.length',
- sprintf( 'BillingPolicy: trial_duration.length must be an integer, got %s.', gettype( $trial_duration['length'] ) ),
- array( 'type' => 'integer' )
- );
- }
- if ( $trial_duration['length'] <= 0 ) {
- return new InvalidInputException(
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'trial_duration.length',
- sprintf( 'BillingPolicy: trial_duration.length must be positive, got %d.', $trial_duration['length'] ),
- array( 'min' => 1 )
- );
- }
-
- return null;
- }
-
- /**
- * The error for a missing, non-string or unknown trial unit, or null.
- *
- * @param array<array-key, mixed> $trial_duration Trial duration.
- */
- private static function get_trial_unit_error( array $trial_duration ): ?InvalidInputException {
- if ( ! array_key_exists( 'unit', $trial_duration ) ) {
- return new InvalidInputException( ValidationErrorCode::REQUIRED, 'trial_duration.unit', 'BillingPolicy: trial_duration.unit is required.' );
- }
- if ( ! is_string( $trial_duration['unit'] ) ) {
- return new InvalidInputException(
- ValidationErrorCode::INVALID_TYPE,
- 'trial_duration.unit',
- sprintf( 'BillingPolicy: trial_duration.unit must be a string, got %s.', gettype( $trial_duration['unit'] ) ),
- array( 'type' => 'string' )
- );
- }
-
- return self::get_unit_error( $trial_duration['unit'], 'trial_duration.unit' );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
deleted file mode 100644
index 2fed06c187a..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
+++ /dev/null
@@ -1,134 +0,0 @@
-<?php
-/**
- * DeliveryPolicy - an optional parser for a delivery anchors, cutoff, and
- * intent payload.
- *
- * An opt-in parser for extensions: an extension may use {@see self::from_array()} to
- * parse its plan delivery arrays. No engine code consumes it yet; the engine stores
- * plan policies opaquely and does not construct this on plan reads or writes.
- * Deliberately thin for now. The array shape it parses:
- * {
- * anchors: [{ type: 'MONTHDAY', day: int }, { type: 'YEARDAY', day: int, month: int }, ...],
- * cutoff: ?mixed,
- * intent: ?mixed
- * }
- *
- * The shipping/delivery policy parameter set is a new concept still being
- * designed, so `cutoff` and `intent` are passed through verbatim and anchor
- * entries stay as plain associative arrays until a call site needs typed access.
- *
- * Lives in the WordPress-free Core zone.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * DeliveryPolicy value object.
- *
- * Immutable. Construct via {@see self::from_array()} when hydrating from a
- * stored row, or via the constructor when building one in code.
- */
-final class DeliveryPolicy {
-
- /**
- * Anchor entries. Each: `{type, day, month?}`.
- *
- * @var array<int, array<array-key, mixed>>
- */
- private $anchors;
-
- /**
- * Cutoff window - shape to be designed; passed through verbatim.
- *
- * @var mixed
- */
- private $cutoff;
-
- /**
- * Delivery intent - shape to be designed; passed through verbatim.
- *
- * @var mixed
- */
- private $intent;
-
- /**
- * Build a delivery policy.
- *
- * @param array<int, array<array-key, mixed>> $anchors Anchor entries.
- * @param mixed $cutoff Cutoff window.
- * @param mixed $intent Delivery intent.
- */
- public function __construct( array $anchors, $cutoff, $intent ) {
- $this->anchors = $anchors;
- $this->cutoff = $cutoff;
- $this->intent = $intent;
- }
-
- /**
- * Hydrate from the JSON-decoded `delivery_policy` column shape.
- *
- * Missing keys default to safe values - empty array for `anchors`, null for
- * `cutoff` and `intent`.
- *
- * @param array<array-key, mixed> $data Decoded delivery_policy row.
- */
- public static function from_array( array $data ): self {
- $anchors = array();
- $raw_anchors = $data['anchors'] ?? null;
- if ( is_array( $raw_anchors ) ) {
- foreach ( $raw_anchors as $anchor ) {
- if ( is_array( $anchor ) ) {
- $anchors[] = $anchor;
- }
- }
- }
-
- return new self( $anchors, $data['cutoff'] ?? null, $data['intent'] ?? null );
- }
-
- /**
- * Anchor entries describing when in the cycle a charge fires.
- *
- * @return array<int, array<array-key, mixed>>
- */
- public function get_anchors(): array {
- return $this->anchors;
- }
-
- /**
- * Cutoff window. Shape to be designed; returned verbatim.
- *
- * @return mixed
- */
- public function get_cutoff() {
- return $this->cutoff;
- }
-
- /**
- * Delivery intent. Shape to be designed; returned verbatim.
- *
- * @return mixed
- */
- public function get_intent() {
- return $this->intent;
- }
-
- /**
- * Serialize back to the JSON column shape. Lossless round-trip with from_array().
- *
- * @return array<string, mixed>
- */
- public function to_array(): array {
- return array(
- 'anchors' => $this->anchors,
- 'cutoff' => $this->cutoff,
- 'intent' => $this->intent,
- );
- }
-}
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 5f4b4a3f766..b8177d5358f 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,6 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
use LogicException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
defined( 'ABSPATH' ) || exit;
@@ -101,23 +100,6 @@ final class PlanSnapshot {
return isset( $this->data['selling_plan_id'] ) ? Coercion::coerce_int( $this->data['selling_plan_id'] ) : null;
}
- /**
- * The frozen billing cadence captured at signup, parsed with
- * {@see BillingPolicy::from_array()}, so it holds after the source plan is edited or deleted. Null when the payload carries no billing
- * policy array; throws when one is present but unusable, so a caller can log why
- * before falling back.
- *
- * @throws InvalidInputException If the stored policy does not parse or has no usable cadence.
- */
- public function read_billing_policy(): ?BillingPolicy {
- $policy = $this->data['billing_policy'] ?? null;
- if ( ! is_array( $policy ) ) {
- return null;
- }
-
- return BillingPolicy::from_array( Coercion::coerce_string_keyed( $policy ) );
- }
-
/**
* The frozen pricing payload captured at signup, returned as captured. The
* owning extension interprets it. Null when absent, explicitly null, or not an
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
index d15707aa4ea..bcc01a3ccdb 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/EngineIntegrationTestCase.php
@@ -20,7 +20,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractLockState;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
@@ -435,6 +434,9 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
? new DateTimeImmutable( '@' . $paid->getTimestamp() )
: new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
+ // The engine does not read the plan's cadence, so the fixture bills monthly like make_plan().
+ $next_payment = $start->setTimezone( new DateTimeZone( 'UTC' ) )->modify( '+1 month' );
+
$items = array();
foreach ( $order->get_items() as $item ) {
if ( $item instanceof WC_Order_Item_Product ) {
@@ -465,7 +467,7 @@ abstract class EngineIntegrationTestCase extends WP_UnitTestCase {
'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' => BillingPolicy::from_array( $plan->get_billing_policy() ?? array() )->compute_first_renewal_from( $start ),
+ 'next_payment_gmt' => $next_payment,
'billing_total' => (string) $order->get_total(),
'discount_total' => (string) $order->get_total_discount(),
'shipping_total' => (string) $order->get_shipping_total(),
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
index 7b8c8d86b35..e957704b653 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
@@ -30,7 +30,7 @@ class InvalidInputExceptionTest extends TestCase {
throw new InvalidInputException(
ValidationErrorCode::OUT_OF_BOUNDS,
'trial_duration.length',
- 'BillingPolicy: trial length must be positive.',
+ 'Trial length must be positive.',
array( 'min' => 1 )
);
} catch ( DomainException $caught ) {
@@ -41,7 +41,7 @@ class InvalidInputExceptionTest extends TestCase {
$this->assertSame( 'out_of_bounds', $e->get_error_code() );
$this->assertSame( 'trial_duration.length', $e->get_field() );
$this->assertSame( array( 'min' => 1 ), $e->get_params() );
- $this->assertSame( 'BillingPolicy: trial length must be positive.', $e->getMessage() );
+ $this->assertSame( 'Trial length must be positive.', $e->getMessage() );
}
/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
deleted file mode 100644
index c727ec96aca..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
+++ /dev/null
@@ -1,573 +0,0 @@
-<?php
-/**
- * Unit tests for BillingPolicy.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy
- */
-class BillingPolicyTest extends TestCase {
-
- use AssertsInvalidInput;
-
- /**
- * @testdox Each refusal reports its code, field and params.
- * @dataProvider provide_refusals
- *
- * @param callable $operation Operation expected to be refused.
- * @param string $code Expected error code.
- * @param string $field Expected field.
- * @param array<string, mixed> $params Expected params.
- */
- public function test_each_refusal_reports_its_code_field_and_params( callable $operation, string $code, string $field, array $params ): void {
- $this->assert_invalid_input( $operation, $code, $field, $params );
- }
-
- /**
- * @return array<string, array{0: callable, 1: string, 2: string, 3: array<string, mixed>}>
- */
- public function provide_refusals(): array {
- $units = array( 'allowed' => array( 'day', 'week', 'month', 'year' ) );
-
- return array(
- 'interval not positive' => array(
- static function (): void {
- new BillingPolicy( 'month', 0, null, null, null );
- },
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'interval',
- array( 'min' => 1 ),
- ),
- 'unknown period' => array(
- static function (): void {
- new BillingPolicy( 'fortnight', 1, null, null, null );
- },
- ValidationErrorCode::NOT_IN_ENUM,
- 'period',
- $units,
- ),
- 'period missing' => array(
- static function (): void {
- BillingPolicy::from_array( array( 'interval' => 1 ) );
- },
- ValidationErrorCode::REQUIRED,
- 'period',
- array(),
- ),
- 'interval missing' => array(
- static function (): void {
- BillingPolicy::from_array( array( 'period' => 'month' ) );
- },
- ValidationErrorCode::REQUIRED,
- 'interval',
- array(),
- ),
- 'period not a string' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 1,
- 'interval' => 1,
- )
- );
- },
- ValidationErrorCode::INVALID_TYPE,
- 'period',
- array( 'type' => 'string' ),
- ),
- 'interval not an integer' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => '2',
- )
- );
- },
- ValidationErrorCode::INVALID_TYPE,
- 'interval',
- array( 'type' => 'integer' ),
- ),
- 'trial not an object' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => 'P7D',
- )
- );
- },
- ValidationErrorCode::INVALID_TYPE,
- 'trial_duration',
- array( 'type' => 'object' ),
- ),
- 'min_cycles negative' => array(
- static function (): void {
- new BillingPolicy( 'month', 1, -1, null, null );
- },
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'min_cycles',
- array( 'min' => 0 ),
- ),
- 'max_cycles negative' => array(
- static function (): void {
- new BillingPolicy( 'month', 1, null, -1, null );
- },
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'max_cycles',
- array( 'min' => 0 ),
- ),
- 'min_cycles above max' => array(
- static function (): void {
- new BillingPolicy( 'month', 1, 5, 2, null );
- },
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'min_cycles',
- array( 'max' => 2 ),
- ),
- 'trial length missing' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => array( 'unit' => 'day' ),
- )
- );
- },
- ValidationErrorCode::REQUIRED,
- 'trial_duration.length',
- array(),
- ),
- 'trial unit missing' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => array( 'length' => 7 ),
- )
- );
- },
- ValidationErrorCode::REQUIRED,
- 'trial_duration.unit',
- array(),
- ),
- 'trial length not integer' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => array(
- 'length' => '7',
- 'unit' => 'day',
- ),
- )
- );
- },
- ValidationErrorCode::INVALID_TYPE,
- 'trial_duration.length',
- array( 'type' => 'integer' ),
- ),
- 'trial unit not a string' => array(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => array(
- 'length' => 7,
- 'unit' => 1,
- ),
- )
- );
- },
- ValidationErrorCode::INVALID_TYPE,
- 'trial_duration.unit',
- array( 'type' => 'string' ),
- ),
- 'trial length not positive' => array(
- static function (): void {
- new BillingPolicy(
- 'month',
- 1,
- null,
- null,
- array(
- 'length' => 0,
- 'unit' => 'day',
- )
- );
- },
- ValidationErrorCode::OUT_OF_BOUNDS,
- 'trial_duration.length',
- array( 'min' => 1 ),
- ),
- 'unknown trial unit' => array(
- static function (): void {
- new BillingPolicy(
- 'month',
- 1,
- null,
- null,
- array(
- 'length' => 7,
- 'unit' => 'fortnight',
- )
- );
- },
- ValidationErrorCode::NOT_IN_ENUM,
- 'trial_duration.unit',
- $units,
- ),
- );
- }
-
- /**
- * @testdox from_array refuses an unusable cadence.
- * @testWith ["decade", 1, "BillingPolicy: invalid period \"decade\"."]
- * ["month", 0, "BillingPolicy: interval must be positive, got 0."]
- * ["day", -1, "BillingPolicy: interval must be positive, got -1."]
- *
- * @param string $period Period.
- * @param int $interval Interval.
- * @param string $message Expected exception message.
- */
- public function test_from_array_refuses_an_unusable_cadence( string $period, int $interval, string $message ): void {
- $data = array(
- 'period' => $period,
- 'interval' => $interval,
- );
-
- $this->expectException( InvalidInputException::class );
- $this->expectExceptionMessage( $message );
-
- BillingPolicy::from_array( $data );
- }
-
- /**
- * @testdox from_array accepts a usable cadence.
- */
- public function test_from_array_accepts_a_usable_cadence(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'week',
- 'interval' => 2,
- )
- );
-
- $this->assertSame( 'week', $policy->get_period() );
- $this->assertSame( 2, $policy->get_interval() );
- }
-
- public function test_round_trips_through_array(): void {
- $data = array(
- 'period' => 'month',
- 'interval' => 2,
- 'min_cycles' => 1,
- 'max_cycles' => 12,
- 'trial_duration' => array(
- 'length' => 14,
- 'unit' => 'day',
- ),
- );
-
- $policy = BillingPolicy::from_array( $data );
-
- $this->assertSame( 'month', $policy->get_period() );
- $this->assertSame( 2, $policy->get_interval() );
- $this->assertSame( 1, $policy->get_min_cycles() );
- $this->assertSame( 12, $policy->get_max_cycles() );
- $this->assertSame( $data['trial_duration'], $policy->get_trial_duration() );
- $this->assertSame( $data, $policy->to_array() );
- }
-
- public function test_missing_nullable_keys_default_to_null(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'week',
- 'interval' => 1,
- )
- );
-
- $this->assertNull( $policy->get_min_cycles() );
- $this->assertNull( $policy->get_max_cycles() );
- $this->assertNull( $policy->get_trial_duration() );
- }
-
- public function test_compute_next_renewal_adds_one_cadence_in_utc(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- )
- );
-
- $anchor = new DateTimeImmutable( '2026-01-15 10:00:00', new DateTimeZone( 'UTC' ) );
- $next = $policy->compute_next_renewal_from( $anchor );
-
- $this->assertSame( '2026-02-15 10:00:00', $next->format( 'Y-m-d H:i:s' ) );
- $this->assertSame( 'UTC', $next->getTimezone()->getName() );
- }
-
- public function test_compute_first_renewal_honours_trial(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => array(
- 'length' => 7,
- 'unit' => 'day',
- ),
- )
- );
-
- $start = new DateTimeImmutable( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) );
- $first = $policy->compute_first_renewal_from( $start );
-
- $this->assertSame( '2026-01-08 00:00:00', $first->format( 'Y-m-d H:i:s' ) );
- }
-
- public function test_compute_first_renewal_without_trial_matches_next(): void {
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'year',
- 'interval' => 1,
- )
- );
-
- $start = new DateTimeImmutable( '2026-03-10 12:00:00', new DateTimeZone( 'UTC' ) );
-
- $this->assertEquals(
- $policy->compute_next_renewal_from( $start ),
- $policy->compute_first_renewal_from( $start )
- );
- }
-
- /**
- * @testdox from_array reports every invalid value in one exception.
- */
- public function test_from_array_reports_every_invalid_value(): void {
- $this->assert_invalid_inputs(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'interval' => 0,
- 'min_cycles' => -1,
- 'trial_duration' => array( 'length' => 'x' ),
- )
- );
- },
- array(
- array( ValidationErrorCode::REQUIRED, 'period', array() ),
- array( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', array( 'min' => 1 ) ),
- array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'min' => 0 ) ),
- array( ValidationErrorCode::INVALID_TYPE, 'trial_duration.length', array( 'type' => 'integer' ) ),
- array( ValidationErrorCode::REQUIRED, 'trial_duration.unit', array() ),
- )
- );
- }
-
- /**
- * @testdox from_array reports a bad period, trial length and trial unit together.
- */
- public function test_from_array_reports_the_trial_values_with_the_other_fields(): void {
- $this->assert_invalid_inputs(
- static function (): void {
- BillingPolicy::from_array(
- array(
- 'period' => 'fortnight',
- 'interval' => 1,
- 'trial_duration' => array(
- 'length' => 0,
- 'unit' => 'fortnight',
- ),
- )
- );
- },
- array(
- array( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
- array( ValidationErrorCode::OUT_OF_BOUNDS, 'trial_duration.length', array( 'min' => 1 ) ),
- array( ValidationErrorCode::NOT_IN_ENUM, 'trial_duration.unit', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
- )
- );
- }
-
- /**
- * @testdox The construction reports every invalid value in one exception.
- */
- public function test_construction_reports_every_invalid_value(): void {
- $this->assert_invalid_inputs(
- static function (): void {
- new BillingPolicy( 'fortnight', 0, null, -2, null );
- },
- array(
- array( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
- array( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', array( 'min' => 1 ) ),
- array( ValidationErrorCode::OUT_OF_BOUNDS, 'max_cycles', array( 'min' => 0 ) ),
- )
- );
- }
-
- /**
- * @testdox min_cycles above max_cycles is checked only when both bounds pass their own checks.
- */
- public function test_the_cycle_bounds_check_runs_only_on_valid_bounds(): void {
- $this->assert_invalid_inputs(
- static function (): void {
- new BillingPolicy( 'month', 1, 5, 2, null );
- },
- array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'max' => 2 ) ) )
- );
- $this->assert_invalid_inputs(
- static function (): void {
- new BillingPolicy( 'month', 1, -1, 2, null );
- },
- array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'min' => 0 ) ) )
- );
- }
-
- public function test_non_array_trial_duration_throws(): void {
- $this->expectException( InvalidInputException::class );
- $this->expectExceptionMessage( 'BillingPolicy: trial_duration must be null or an array, got string.' );
-
- BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'trial_duration' => 'P7D',
- )
- );
- }
-
- /**
- * @dataProvider provide_min_and_max_cycles_validation_cases
- * @param string|null $expected_exception_message The expected exception message, or null if no exception is expected.
- * @param int|null $min_cycles The minimum number of cycles.
- * @param int|null $max_cycles The maximum number of cycles.
- */
- public function test_min_and_max_cycles_validation( ?string $expected_exception_message, ?int $min_cycles, ?int $max_cycles ): void {
- if ( null !== $expected_exception_message ) {
- $this->expectException( InvalidInputException::class );
- $this->expectExceptionMessage( $expected_exception_message );
- }
-
- $policy = BillingPolicy::from_array(
- array(
- 'period' => 'month',
- 'interval' => 1,
- 'min_cycles' => $min_cycles,
- 'max_cycles' => $max_cycles,
- )
- );
-
- if ( null === $expected_exception_message ) {
- $this->assertSame( $min_cycles, $policy->get_min_cycles() );
- $this->assertSame( $max_cycles, $policy->get_max_cycles() );
- }
- }
-
- /**
- * @return array<string, array{expected_exception_message: string|null, min_cycles: int|null, max_cycles: int|null}>
- */
- public function provide_min_and_max_cycles_validation_cases(): array {
- return array(
- 'min_cycles is 0, max_cycles is null' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => 0,
- 'max_cycles' => null,
- ),
- 'min_cycles is 0, max_cycles is positive' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => 0,
- 'max_cycles' => 10,
- ),
- 'min_cycles is 0, max_cycles is less than 0' => array(
- 'expected_exception_message' => 'BillingPolicy: max_cycles must be 0 or greater, got -4.',
- 'min_cycles' => 0,
- 'max_cycles' => -4,
- ),
- 'max_cycles is 0, min_cycles is null' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => null,
- 'max_cycles' => 0,
- ),
- 'max_cycles is 0, min_cycles is positive' => array(
- 'expected_exception_message' => 'BillingPolicy: min_cycles cannot exceed max_cycles, got 5 and 0.',
- 'min_cycles' => 5,
- 'max_cycles' => 0,
- ),
- 'max_cycles is 0, min_cycles is greater than max_cycles' => array(
- 'expected_exception_message' => 'BillingPolicy: min_cycles cannot exceed max_cycles, got 5 and 0.',
- 'min_cycles' => 5,
- 'max_cycles' => 0,
- ),
- 'max_cycles is positive, min_cycles is null' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => null,
- 'max_cycles' => 10,
- ),
- 'max_cycles is positive, min_cycles is positive' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => 1,
- 'max_cycles' => 10,
- ),
- 'max_cycles is positive, min_cycles is the same as max_cycles' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => 10,
- 'max_cycles' => 10,
- ),
- 'min_cycles is positive, max_cycles is null' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => 1,
- 'max_cycles' => null,
- ),
- 'min_cycles is positive, max_cycles is positive' => array(
- 'expected_exception_message' => null,
- 'min_cycles' => 1,
- 'max_cycles' => 10,
- ),
- 'min_cycles is positive, max_cycles is less than min_cycles' => array(
- 'expected_exception_message' => 'BillingPolicy: min_cycles cannot exceed max_cycles, got 10 and 9.',
- 'min_cycles' => 10,
- 'max_cycles' => 9,
- ),
- 'min_cycles is negative, max_cycles is null' => array(
- 'expected_exception_message' => 'BillingPolicy: min_cycles must be 0 or greater, got -1.',
- 'min_cycles' => -1,
- 'max_cycles' => null,
- ),
- 'min_cycles is negative, max_cycles is positive' => array(
- 'expected_exception_message' => 'BillingPolicy: min_cycles must be 0 or greater, got -1.',
- 'min_cycles' => -1,
- 'max_cycles' => 10,
- ),
- 'min_cycles is negative, max_cycles is less than min_cycles' => array(
- 'expected_exception_message' => 'BillingPolicy: min_cycles must be 0 or greater, got -1.',
- 'min_cycles' => -1,
- 'max_cycles' => -1,
- ),
- 'min_cycles is positive, max_cycles is negative' => array(
- 'expected_exception_message' => 'BillingPolicy: max_cycles must be 0 or greater, got -1.',
- 'min_cycles' => 1,
- 'max_cycles' => -1,
- ),
- );
- }
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
index 384c456d33d..56a305711d4 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -10,8 +10,6 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;
@@ -101,80 +99,6 @@ class PlanSnapshotTest extends TestCase {
);
}
- /**
- * @testdox read_billing_policy reconstructs the frozen cadence from the payload.
- */
- public function test_read_billing_policy_reconstructs_the_frozen_cadence(): void {
- $snapshot = PlanSnapshot::from_array(
- array(
- 'selling_plan_id' => 7,
- 'name' => 'Monthly box',
- 'billing_policy' => array(
- 'period' => 'month',
- 'interval' => 3,
- 'min_cycles' => null,
- 'max_cycles' => 12,
- 'trial_duration' => null,
- ),
- )
- );
-
- $policy = $snapshot->read_billing_policy();
-
- $this->assertInstanceOf( BillingPolicy::class, $policy );
- $this->assertSame( 'month', $policy->get_period() );
- $this->assertSame( 3, $policy->get_interval() );
- $this->assertSame( 12, $policy->get_max_cycles() );
- }
-
- /**
- * @testdox read_billing_policy throws for a structurally-invalid stored policy.
- */
- public function test_read_billing_policy_throws_for_an_unreadable_policy(): void {
- // `interval` missing: BillingPolicy::from_array() refuses it.
- $snapshot = PlanSnapshot::from_array(
- array(
- 'billing_policy' => array( 'period' => 'month' ),
- )
- );
-
- $this->expectException( InvalidInputException::class );
- $snapshot->read_billing_policy();
- }
-
- /**
- * @testdox read_billing_policy throws for a policy with no usable cadence ($label).
- *
- * @testWith ["unknown period", "decade", 1]
- * ["zero interval", "month", 0]
- * ["negative interval", "week", -2]
- *
- * @param string $label Case label.
- * @param string $period Stored period.
- * @param int $interval Stored interval.
- */
- public function test_a_policy_without_a_usable_cadence_is_not_read( string $label, string $period, int $interval ): void {
- unset( $label );
- $snapshot = PlanSnapshot::from_array(
- array(
- 'billing_policy' => array(
- 'period' => $period,
- 'interval' => $interval,
- ),
- )
- );
-
- $this->expectException( InvalidInputException::class );
- $snapshot->read_billing_policy();
- }
-
- /**
- * @testdox read_billing_policy is null when the payload carries no billing policy.
- */
- public function test_read_billing_policy_is_null_when_absent(): void {
- $this->assertNull( PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) )->read_billing_policy() );
- }
-
/**
* @testdox get_pricing_policy returns the frozen payload exactly as captured.
*/