Commit e90f3ee7099 for woocommerce
commit e90f3ee70990fa08988d4925a349b7164994e8fc
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Tue Jun 23 17:43:08 2026 +0200
Resolve PHPStan level 9 findings in the subscriptions engine package (#65928)
* Add ScalarCoercion trait and type-safe value object hydration
Introduce a WordPress-free ScalarCoercion trait that guards-then-casts mixed
values from decoded rows and argument maps. Rebuild PricingPolicy, BillingPolicy
and DeliveryPolicy from_array() to produce provably-typed shapes (normalizing
each entry, narrowing numeric casts, filtering anchors to arrays), resolving
their level-9 PHPStan findings. Add a PricingPolicy test for fee normalization.
* Type-safe hydration for Contract, Plan and PlanGroup entities
Make Contract's private constructor the single coercion boundary so its 24
property assignments are typed, and pass raw values from create()/from_storage().
Add instanceof validation for Plan's policy arguments and coerce scalar fields in
Plan and PlanGroup hydration. Resolves the level-9 PHPStan findings in the
entities while preserving validation behaviour.
* Type-safe row hydration in ContractRepository and RenewalEngine
Guard and coerce wpdb row values when reading items, addresses and meta, and type
the find_* return values. Guard the order list before iteration in RenewalEngine.
Resolves the remaining level-9 PHPStan findings in the storage and renewal layers.
* Resolve PHPStan findings in the engine test suite
Narrow nullable repository results and contract ids with assertInstanceOf/
assertNotNull, type a data provider and a test helper parameter, narrow untyped
globals with assertIsArray, and drop assertions that duplicate stronger checks.
Takes the package to zero level-9 PHPStan errors on PHP 7.4 and 8.4.
* Apply review feedback: accurate guard comment and fuller fee test
Clarify that the RenewalEngine non-array guard narrows the declared
WC_Order[]|stdClass return type (this query does not paginate), and assert the
second fee's taxable also normalizes to false in the fee-normalization test.
* Add changelog entry for the PHPStan green-up
* Validate required fields in Contract::create()
- Reject missing/invalid customer_id (non-negative; 0 is a guest),
selling_plan_id and origin_order_id (positive), and currency and start_gmt
(non-empty) with a DomainException instead of silently coercing to 0/''
- Document that PricingPolicy::from_array() is the canonical normalization point
and intentionally drops unknown keys
- Correct the Contract::coerce_items() docblock (it filters array entries, it
does not normalize item fields)
- Add ContractTest coverage for create() required-field validation and the guest
customer_id=0 case
* Harden hydration boundaries against corrupted stored data
A WordPress database can be mutated outside this engine's flows, and these
values drive money and scheduling, so hydration fails loud on corruption instead
of silently mis-charging.
- PricingPolicy: interpret fee `taxable` as a real boolean (filter_var) so a
stored string like 'false' is not treated as taxable and inflate totals
- ScalarCoercion: reject fractional/exponent inputs in coerce_int/
coerce_nullable_int instead of silently truncating; BillingPolicy min/max
cycles now use the hardened helper
- Contract::from_storage: reject a non-integer cycle_count and an invalid
schedule_source rather than coercing them to a silent default
- Plan::from_storage: re-validate the stored pricing_policy so a tampered rule
(negative or over-100) fails loud instead of feeding billing math
- Add unit coverage for each guard
* Default to null cycle_count instead of 0 for validation
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/fix-subscriptions-engine-phpstan-findings b/packages/php/woocommerce-subscriptions-engine/changelog/fix-subscriptions-engine-phpstan-findings
new file mode 100644
index 00000000000..9d1f9110230
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/fix-subscriptions-engine-phpstan-findings
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Resolve all PHPStan level 9 findings across the engine source and tests by adding type-safe hydration coercion at the entity, value object and repository boundaries (behavior-preserving for valid data).
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 b3783ee8a91..8924701baf5 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -17,6 +17,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
use DomainException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\InstrumentRef;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
defined( 'ABSPATH' ) || exit;
@@ -28,6 +29,8 @@ defined( 'ABSPATH' ) || exit;
*/
final class Contract {
+ use ScalarCoercion;
+
const SCHEDULE_SOURCE_PRIMITIVE = 'primitive';
const SCHEDULE_SOURCE_GATEWAY = 'gateway';
@@ -198,7 +201,7 @@ final class Contract {
/**
* Addresses keyed by type ('billing' | 'shipping').
*
- * @var array{ billing: array<string, mixed>, shipping: array<string, mixed> }
+ * @var array<string, array<string, mixed>>
*/
private $addresses;
@@ -215,81 +218,175 @@ final class Contract {
* @param array<string, mixed> $fields Internal field map.
*/
private function __construct( array $fields ) {
- $this->id = $fields['id'];
- $this->status = $fields['status'];
- $this->customer_id = $fields['customer_id'];
- $this->currency = $fields['currency'];
- $this->selling_plan_id = $fields['selling_plan_id'];
- $this->origin_order_id = $fields['origin_order_id'];
- $this->extension_slug = $fields['extension_slug'];
- $this->payment_method = $fields['payment_method'];
- $this->payment_method_title = $fields['payment_method_title'];
- $this->payment_token_id = $fields['payment_token_id'];
- $this->billing_total = $fields['billing_total'];
- $this->discount_total = $fields['discount_total'];
- $this->shipping_total = $fields['shipping_total'];
- $this->tax_total = $fields['tax_total'];
- $this->start_gmt = $fields['start_gmt'];
- $this->next_payment_gmt = $fields['next_payment_gmt'];
- $this->last_payment_gmt = $fields['last_payment_gmt'];
- $this->last_attempt_gmt = $fields['last_attempt_gmt'];
- $this->trial_end_gmt = $fields['trial_end_gmt'];
- $this->end_gmt = $fields['end_gmt'];
- $this->cycle_count = $fields['cycle_count'];
- $this->schedule_source = $fields['schedule_source'];
- $this->items = $fields['items'];
- $this->addresses = $fields['addresses'];
- $this->meta = $fields['meta'];
+ $this->id = self::coerce_nullable_int( $fields['id'] ?? null );
+ $this->status = self::coerce_string( $fields['status'] ?? null );
+ $this->customer_id = self::coerce_int( $fields['customer_id'] ?? null );
+ $this->currency = self::coerce_string( $fields['currency'] ?? null );
+ $this->selling_plan_id = self::coerce_int( $fields['selling_plan_id'] ?? null );
+ $this->origin_order_id = self::coerce_int( $fields['origin_order_id'] ?? null );
+ $this->extension_slug = self::coerce_nullable_string( $fields['extension_slug'] ?? null );
+ $this->payment_method = self::coerce_nullable_string( $fields['payment_method'] ?? null );
+ $this->payment_method_title = self::coerce_nullable_string( $fields['payment_method_title'] ?? null );
+ $this->payment_token_id = self::coerce_nullable_int( $fields['payment_token_id'] ?? null );
+ $this->billing_total = self::coerce_string( $fields['billing_total'] ?? null, '0' );
+ $this->discount_total = self::coerce_string( $fields['discount_total'] ?? null, '0' );
+ $this->shipping_total = self::coerce_string( $fields['shipping_total'] ?? null, '0' );
+ $this->tax_total = self::coerce_string( $fields['tax_total'] ?? null, '0' );
+ $this->start_gmt = self::coerce_string( $fields['start_gmt'] ?? null );
+ $this->next_payment_gmt = self::coerce_nullable_string( $fields['next_payment_gmt'] ?? null );
+ $this->last_payment_gmt = self::coerce_nullable_string( $fields['last_payment_gmt'] ?? null );
+ $this->last_attempt_gmt = self::coerce_nullable_string( $fields['last_attempt_gmt'] ?? null );
+ $this->trial_end_gmt = self::coerce_nullable_string( $fields['trial_end_gmt'] ?? null );
+ $this->end_gmt = self::coerce_nullable_string( $fields['end_gmt'] ?? null );
+ $this->cycle_count = self::coerce_int( $fields['cycle_count'] ?? null );
+ $this->schedule_source = self::coerce_string( $fields['schedule_source'] ?? null );
+ $this->items = self::coerce_items( $fields['items'] ?? null );
+ $this->addresses = self::coerce_addresses( $fields['addresses'] ?? null );
+ $this->meta = self::coerce_meta( $fields['meta'] ?? null );
+ }
+
+ /**
+ * Filter raw items to retain only array-valued entries.
+ *
+ * Individual item field structure is not normalized here; each kept entry is
+ * passed through as-is.
+ *
+ * @param mixed $value Raw items value.
+ * @return array<int, array<string, mixed>>
+ */
+ private static function coerce_items( $value ): array {
+ $items = array();
+ if ( is_array( $value ) ) {
+ foreach ( $value as $item ) {
+ if ( is_array( $item ) ) {
+ $items[] = $item;
+ }
+ }
+ }
+ return $items;
+ }
+
+ /**
+ * Coerce raw address rows to a type => fields map.
+ *
+ * A flat passthrough keyed by address type: only the types actually present
+ * (and array-valued) are kept, so a contract with no addresses round-trips to
+ * an empty map rather than two empty billing/shipping rows.
+ *
+ * @param mixed $value Raw addresses value.
+ * @return array<string, array<string, mixed>>
+ */
+ private static function coerce_addresses( $value ): array {
+ $addresses = array();
+ if ( is_array( $value ) ) {
+ foreach ( $value as $type => $address ) {
+ if ( is_array( $address ) ) {
+ $addresses[ (string) $type ] = $address;
+ }
+ }
+ }
+ return $addresses;
+ }
+
+ /**
+ * Coerce raw meta to a string => string map.
+ *
+ * @param mixed $value Raw meta value.
+ * @return array<string, string>
+ */
+ private static function coerce_meta( $value ): array {
+ $meta = array();
+ if ( is_array( $value ) ) {
+ foreach ( $value as $key => $val ) {
+ $meta[ (string) $key ] = is_scalar( $val ) ? (string) $val : '';
+ }
+ }
+ return $meta;
}
/**
* Build a new, unsaved contract.
*
+ * Required: `selling_plan_id` and `origin_order_id` (positive integers),
+ * `customer_id` (a non-negative integer; 0 is a guest), and `currency`,
+ * `start_gmt` (non-empty strings). These are validated up front so an omitted
+ * field fails loud here rather than coercing to a silent `0`/`''` and producing
+ * an observably invalid contract. All other fields are optional and default in
+ * the constructor.
+ *
* @param array<string, mixed> $args Contract attributes.
- * @throws DomainException If the contract attributes are not valid.
+ * @throws DomainException If a required attribute is missing or invalid.
*/
public static function create( array $args ): self {
- $status = (string) ( $args['status'] ?? ContractStatus::ACTIVE );
+ // customer_id 0 is a valid guest contract, so it is non-negative rather
+ // than strictly positive; an absent key is still rejected (null is not numeric).
+ $customer_id = $args['customer_id'] ?? null;
+ if ( ! is_numeric( $customer_id ) || (int) $customer_id < 0 ) {
+ throw new DomainException(
+ 'Contract: customer_id is required and must be a non-negative integer.'
+ );
+ }
+
+ foreach ( array( 'selling_plan_id', 'origin_order_id' ) as $required_id ) {
+ $value = $args[ $required_id ] ?? null;
+ if ( ! is_numeric( $value ) || (int) $value <= 0 ) {
+ throw new DomainException(
+ sprintf( 'Contract: %s is required and must be a positive integer.', $required_id )
+ );
+ }
+ }
+
+ foreach ( array( 'currency', 'start_gmt' ) as $required_string ) {
+ $value = $args[ $required_string ] ?? null;
+ if ( ! is_scalar( $value ) || '' === (string) $value ) {
+ throw new DomainException(
+ sprintf( 'Contract: %s is required and must be a non-empty string.', $required_string )
+ );
+ }
+ }
+
+ $status = self::coerce_string( $args['status'] ?? null, ContractStatus::ACTIVE );
if ( ! ContractStatus::is_valid( $status ) ) {
throw new DomainException(
sprintf( 'Contract: invalid status "%s".', $status )
);
}
- $schedule_source = (string) ( $args['schedule_source'] ?? self::SCHEDULE_SOURCE_PRIMITIVE );
+ $schedule_source = self::coerce_string( $args['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
throw new DomainException(
sprintf( 'Contract: invalid schedule source "%s".', $schedule_source )
);
}
+ // The constructor is the single coercion boundary; pass raw values through.
return new self(
array(
'id' => null,
'status' => $status,
- 'customer_id' => (int) $args['customer_id'],
- 'currency' => (string) $args['currency'],
- 'selling_plan_id' => (int) $args['selling_plan_id'],
- 'origin_order_id' => (int) $args['origin_order_id'],
- 'extension_slug' => is_string( $args['extension_slug'] ?? null ) ? $args['extension_slug'] : null,
- 'payment_method' => is_string( $args['payment_method'] ?? null ) ? $args['payment_method'] : null,
- 'payment_method_title' => is_string( $args['payment_method_title'] ?? null ) ? $args['payment_method_title'] : null,
- 'payment_token_id' => isset( $args['payment_token_id'] ) ? (int) $args['payment_token_id'] : null,
- 'billing_total' => (string) ( $args['billing_total'] ?? '0' ),
- 'discount_total' => (string) ( $args['discount_total'] ?? '0' ),
- 'shipping_total' => (string) ( $args['shipping_total'] ?? '0' ),
- 'tax_total' => (string) ( $args['tax_total'] ?? '0' ),
- 'start_gmt' => (string) $args['start_gmt'],
+ 'customer_id' => $args['customer_id'] ?? null,
+ 'currency' => $args['currency'] ?? null,
+ 'selling_plan_id' => $args['selling_plan_id'] ?? null,
+ 'origin_order_id' => $args['origin_order_id'] ?? null,
+ 'extension_slug' => $args['extension_slug'] ?? null,
+ 'payment_method' => $args['payment_method'] ?? null,
+ 'payment_method_title' => $args['payment_method_title'] ?? null,
+ 'payment_token_id' => $args['payment_token_id'] ?? null,
+ 'billing_total' => $args['billing_total'] ?? null,
+ 'discount_total' => $args['discount_total'] ?? null,
+ 'shipping_total' => $args['shipping_total'] ?? null,
+ 'tax_total' => $args['tax_total'] ?? null,
+ 'start_gmt' => $args['start_gmt'] ?? null,
'next_payment_gmt' => $args['next_payment_gmt'] ?? null,
'last_payment_gmt' => null,
'last_attempt_gmt' => null,
- 'trial_end_gmt' => is_string( $args['trial_end_gmt'] ?? null ) ? $args['trial_end_gmt'] : null,
+ 'trial_end_gmt' => $args['trial_end_gmt'] ?? null,
'end_gmt' => null,
'cycle_count' => 0,
'schedule_source' => $schedule_source,
- 'items' => is_array( $args['items'] ?? null ) ? $args['items'] : array(),
- 'addresses' => is_array( $args['addresses'] ?? null ) ? $args['addresses'] : array(),
- 'meta' => is_array( $args['meta'] ?? null ) ? $args['meta'] : array(),
+ 'items' => $args['items'] ?? null,
+ 'addresses' => $args['addresses'] ?? null,
+ 'meta' => $args['meta'] ?? null,
)
);
}
@@ -301,43 +398,57 @@ final class Contract {
* @param array<int, array<string, mixed>> $items Item rows.
* @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
* @param array<string, string> $meta Meta as key => value.
- * @throws DomainException If the stored cycle_count is negative.
+ * @throws DomainException If the stored cycle_count or schedule_source is invalid.
*/
public static function from_storage( array $row, array $items = array(), array $addresses = array(), array $meta = array() ): self {
- // Hydration is a trust boundary too: a corrupted or migrated row must not
- // smuggle a negative cycle_count past the invariant set_cycle_count()
- // enforces, since that would corrupt renewal cycle and idempotency math.
- $cycle_count = (int) ( $row['cycle_count'] ?? 0 );
+ // Hydration is a trust boundary: a WordPress database can be mutated outside
+ // this engine's flows, and these fields drive money and scheduling math. A
+ // corrupted cycle_count or schedule_source is rejected loudly here rather
+ // than silently mis-charging a renewal or mis-routing the schedule.
+ $cycle_count = self::coerce_nullable_int( $row['cycle_count'] ?? null );
+ if ( null === $cycle_count ) {
+ throw new DomainException(
+ sprintf( 'Contract: stored cycle_count must be an integer, got %s.', gettype( $row['cycle_count'] ?? null ) )
+ );
+ }
if ( $cycle_count < 0 ) {
throw new DomainException(
sprintf( 'Contract: stored cycle_count must be 0 or greater, got %d.', $cycle_count )
);
}
+ $schedule_source = self::coerce_string( $row['schedule_source'] ?? null, self::SCHEDULE_SOURCE_PRIMITIVE );
+ if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
+ throw new DomainException(
+ sprintf( 'Contract: stored schedule source must be primitive or gateway, got "%s".', $schedule_source )
+ );
+ }
+
+ // The constructor is the single coercion boundary; pass raw row values through.
return new self(
array(
- 'id' => isset( $row['id'] ) ? (int) $row['id'] : null,
- 'status' => (string) $row['status'],
- 'customer_id' => (int) $row['customer_id'],
- 'currency' => (string) $row['currency'],
- 'selling_plan_id' => (int) $row['selling_plan_id'],
- 'origin_order_id' => (int) $row['origin_order_id'],
- 'extension_slug' => isset( $row['extension_slug'] ) ? (string) $row['extension_slug'] : null,
- 'payment_method' => isset( $row['payment_method'] ) ? (string) $row['payment_method'] : null,
- 'payment_method_title' => isset( $row['payment_method_title'] ) ? (string) $row['payment_method_title'] : null,
- 'payment_token_id' => isset( $row['payment_token_id'] ) ? (int) $row['payment_token_id'] : null,
- 'billing_total' => (string) ( $row['billing_total'] ?? '0' ),
- 'discount_total' => (string) ( $row['discount_total'] ?? '0' ),
- 'shipping_total' => (string) ( $row['shipping_total'] ?? '0' ),
- 'tax_total' => (string) ( $row['tax_total'] ?? '0' ),
- 'start_gmt' => (string) $row['start_gmt'],
+ 'id' => $row['id'] ?? null,
+ 'status' => $row['status'] ?? null,
+ 'customer_id' => $row['customer_id'] ?? null,
+ 'currency' => $row['currency'] ?? null,
+ 'selling_plan_id' => $row['selling_plan_id'] ?? null,
+ 'origin_order_id' => $row['origin_order_id'] ?? null,
+ 'extension_slug' => $row['extension_slug'] ?? null,
+ 'payment_method' => $row['payment_method'] ?? null,
+ 'payment_method_title' => $row['payment_method_title'] ?? null,
+ 'payment_token_id' => $row['payment_token_id'] ?? null,
+ 'billing_total' => $row['billing_total'] ?? null,
+ 'discount_total' => $row['discount_total'] ?? null,
+ 'shipping_total' => $row['shipping_total'] ?? null,
+ 'tax_total' => $row['tax_total'] ?? null,
+ 'start_gmt' => $row['start_gmt'] ?? null,
'next_payment_gmt' => $row['next_payment_gmt'] ?? null,
'last_payment_gmt' => $row['last_payment_gmt'] ?? null,
'last_attempt_gmt' => $row['last_attempt_gmt'] ?? null,
'trial_end_gmt' => $row['trial_end_gmt'] ?? null,
'end_gmt' => $row['end_gmt'] ?? null,
'cycle_count' => $cycle_count,
- 'schedule_source' => (string) ( $row['schedule_source'] ?? self::SCHEDULE_SOURCE_PRIMITIVE ),
+ 'schedule_source' => $schedule_source,
'items' => $items,
'addresses' => $addresses,
'meta' => $meta,
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 318f6546083..f8353d3b776 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -14,6 +14,7 @@ use InvalidArgumentException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\DeliveryPolicy;
use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
defined( 'ABSPATH' ) || exit;
@@ -25,6 +26,8 @@ defined( 'ABSPATH' ) || exit;
*/
final class Plan {
+ use ScalarCoercion;
+
const DEFAULT_CATEGORY = 'SUBSCRIPTION';
const ALLOWED_POLICY_TYPES = array( 'percentage', 'fixed_amount', 'price' );
@@ -146,41 +149,67 @@ final class Plan {
*/
public static function create( int $group_id, array $args ): self {
$pricing_policy = $args['pricing_policy'] ?? null;
+ if ( null !== $pricing_policy && ! $pricing_policy instanceof PricingPolicy ) {
+ throw new InvalidArgumentException( 'Plan: pricing_policy must be a PricingPolicy instance or null.' );
+ }
if ( null !== $pricing_policy ) {
self::validate_pricing_policy( $pricing_policy );
}
+ $billing_policy = $args['billing_policy'] ?? null;
+ if ( ! $billing_policy instanceof BillingPolicy ) {
+ throw new InvalidArgumentException( 'Plan: billing_policy is required and must be a BillingPolicy instance.' );
+ }
+
+ $delivery_policy = $args['delivery_policy'] ?? null;
+ if ( null !== $delivery_policy && ! $delivery_policy instanceof DeliveryPolicy ) {
+ throw new InvalidArgumentException( 'Plan: delivery_policy must be a DeliveryPolicy instance or null.' );
+ }
+
return new self(
null,
$group_id,
- (string) $args['name'],
- $args['description'] ?? null,
- $args['options'] ?? array(),
- $args['billing_policy'],
- $args['delivery_policy'] ?? null,
+ self::coerce_string( $args['name'] ?? null ),
+ self::coerce_nullable_string( $args['description'] ?? null ),
+ is_array( $args['options'] ?? null ) ? $args['options'] : array(),
+ $billing_policy,
+ $delivery_policy,
$pricing_policy,
- $args['category'] ?? self::DEFAULT_CATEGORY,
- $args['extension_slug'] ?? null
+ self::coerce_string( $args['category'] ?? null, self::DEFAULT_CATEGORY ),
+ self::coerce_nullable_string( $args['extension_slug'] ?? null )
);
}
/**
* Hydrate from a stored row. Policy columns arrive JSON-decoded.
*
+ * The stored pricing policy is re-validated here: a WordPress database can be
+ * mutated outside this engine's flows, and an out-of-range stored rule (a
+ * negative or over-100 value) would otherwise feed billing math silently. We
+ * fail loud on a corrupted row rather than risk a mischarge.
+ *
* @param array<string, mixed> $row Decoded plan row.
+ * @throws InvalidArgumentException If the stored pricing_policy fails validation.
*/
public static function from_storage( array $row ): self {
+ $pricing_policy = isset( $row['pricing_policy'] ) && is_array( $row['pricing_policy'] )
+ ? PricingPolicy::from_array( $row['pricing_policy'] )
+ : null;
+ if ( null !== $pricing_policy ) {
+ self::validate_pricing_policy( $pricing_policy );
+ }
+
return new self(
- isset( $row['id'] ) ? (int) $row['id'] : null,
- (int) $row['group_id'],
- (string) $row['name'],
- isset( $row['description'] ) ? (string) $row['description'] : null,
+ isset( $row['id'] ) ? self::coerce_int( $row['id'] ) : null,
+ self::coerce_int( $row['group_id'] ?? null ),
+ self::coerce_string( $row['name'] ?? null ),
+ self::coerce_nullable_string( $row['description'] ?? null ),
is_array( $row['options'] ?? null ) ? $row['options'] : array(),
- BillingPolicy::from_array( $row['billing_policy'] ),
+ 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,
- isset( $row['pricing_policy'] ) && is_array( $row['pricing_policy'] ) ? PricingPolicy::from_array( $row['pricing_policy'] ) : null,
- (string) ( $row['category'] ?? self::DEFAULT_CATEGORY ),
- isset( $row['extension_slug'] ) ? (string) $row['extension_slug'] : null
+ $pricing_policy,
+ self::coerce_string( $row['category'] ?? null, self::DEFAULT_CATEGORY ),
+ self::coerce_nullable_string( $row['extension_slug'] ?? null )
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanGroup.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanGroup.php
index aabb692933b..a3714b72349 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanGroup.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/PlanGroup.php
@@ -13,6 +13,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -23,6 +25,8 @@ defined( 'ABSPATH' ) || exit;
*/
final class PlanGroup {
+ use ScalarCoercion;
+
/**
* Group id, or null before it is persisted.
*
@@ -83,10 +87,10 @@ final class PlanGroup {
public static function create( array $args ): self {
return new self(
null,
- (string) $args['name'],
- $args['merchant_code'] ?? null,
- $args['options_display'] ?? array(),
- $args['app_id'] ?? null
+ self::coerce_string( $args['name'] ?? null ),
+ self::coerce_nullable_string( $args['merchant_code'] ?? null ),
+ is_array( $args['options_display'] ?? null ) ? $args['options_display'] : array(),
+ self::coerce_nullable_string( $args['app_id'] ?? null )
);
}
@@ -97,11 +101,11 @@ final class PlanGroup {
*/
public static function from_storage( array $row ): self {
return new self(
- isset( $row['id'] ) ? (int) $row['id'] : null,
- (string) $row['name'],
- isset( $row['merchant_code'] ) ? (string) $row['merchant_code'] : null,
+ isset( $row['id'] ) ? self::coerce_int( $row['id'] ) : null,
+ self::coerce_string( $row['name'] ?? null ),
+ self::coerce_nullable_string( $row['merchant_code'] ?? null ),
is_array( $row['options_display'] ?? null ) ? $row['options_display'] : array(),
- isset( $row['app_id'] ) ? (string) $row['app_id'] : null
+ self::coerce_nullable_string( $row['app_id'] ?? null )
);
}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
new file mode 100644
index 00000000000..23e2c92802f
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ScalarCoercion.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ * ScalarCoercion - shared helpers for coercing untyped (mixed) values read from
+ * decoded storage rows or caller-supplied argument maps into the scalar types the
+ * entities and value objects declare.
+ *
+ * Hydration boundaries receive mixed data (JSON-decoded columns, $wpdb string
+ * rows, loosely-typed $args). A blind (int)/(string) cast on such a value is
+ * unsafe - an array or object would warn or fatal. These helpers guard first,
+ * then cast, returning a documented default when the value is not coercible.
+ *
+ * Lives in the WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Scalar coercion helpers for hydration boundaries.
+ */
+trait ScalarCoercion {
+
+ /**
+ * Coerce a value to a string, falling back to a default when it is not scalar.
+ *
+ * @param mixed $value The raw value.
+ * @param string $fallback Returned when $value is not a scalar.
+ */
+ private static function coerce_string( $value, string $fallback = '' ): string {
+ return is_scalar( $value ) ? (string) $value : $fallback;
+ }
+
+ /**
+ * Coerce a value to a string, or null when it is not a scalar.
+ *
+ * @param mixed $value The raw value.
+ */
+ private static function coerce_nullable_string( $value ): ?string {
+ return is_scalar( $value ) ? (string) $value : null;
+ }
+
+ /**
+ * Coerce a value to an int, falling back to a default when it is not an integer.
+ *
+ * Only genuine integers and integer-valued strings are accepted; fractional or
+ * exponent forms (`1.5`, `1e2`) are rejected rather than silently truncated, so
+ * a corrupted identifier or counter falls back instead of changing value.
+ *
+ * @param mixed $value The raw value.
+ * @param int $fallback Returned when $value is not an integer.
+ */
+ private static function coerce_int( $value, int $fallback = 0 ): int {
+ if ( is_int( $value ) ) {
+ return $value;
+ }
+
+ $validated = is_string( $value ) ? filter_var( $value, FILTER_VALIDATE_INT ) : false;
+
+ return false !== $validated ? $validated : $fallback;
+ }
+
+ /**
+ * Coerce a value to an int, or null when it is not an integer.
+ *
+ * Same integer-only rule as {@see self::coerce_int()}: fractional/exponent
+ * forms are rejected rather than truncated.
+ *
+ * @param mixed $value The raw value.
+ */
+ private static function coerce_nullable_int( $value ): ?int {
+ if ( is_int( $value ) ) {
+ return $value;
+ }
+
+ $validated = is_string( $value ) ? filter_var( $value, FILTER_VALIDATE_INT ) : false;
+
+ return false !== $validated ? $validated : null;
+ }
+}
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 6e071e00482..66a354ffedf 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -24,6 +24,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
use DateTimeImmutable;
use DateTimeZone;
use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
defined( 'ABSPATH' ) || exit;
@@ -35,6 +36,8 @@ defined( 'ABSPATH' ) || exit;
*/
final class BillingPolicy {
+ use ScalarCoercion;
+
/**
* Period unit: 'day' | 'week' | 'month' | 'year'.
*
@@ -123,8 +126,8 @@ final class BillingPolicy {
return new self(
(string) $data['period'],
(int) $data['interval'],
- isset( $data['min_cycles'] ) ? (int) $data['min_cycles'] : null,
- isset( $data['max_cycles'] ) ? (int) $data['max_cycles'] : null,
+ self::coerce_nullable_int( $data['min_cycles'] ?? null ),
+ self::coerce_nullable_int( $data['max_cycles'] ?? null ),
$trial
);
}
@@ -284,7 +287,7 @@ final class BillingPolicy {
/**
* Normalize the trial duration.
*
- * @param array{length: int, unit: string}|null $trial_duration The trial duration.
+ * @param array<array-key, mixed>|null $trial_duration The trial duration.
* @return array{length: int, unit: string}|null The normalized trial duration.
* @throws DomainException If the trial duration is not valid.
*/
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
index fd576c38486..c7099062158 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/DeliveryPolicy.php
@@ -37,7 +37,7 @@ final class DeliveryPolicy {
/**
* Anchor entries. Each: `{type, day, month?}`.
*
- * @var array<int, array<string, mixed>>
+ * @var array<int, array<array-key, mixed>>
*/
private $anchors;
@@ -58,9 +58,9 @@ final class DeliveryPolicy {
/**
* Build a delivery policy.
*
- * @param array<int, array<string, mixed>> $anchors Anchor entries.
- * @param mixed $cutoff Cutoff window.
- * @param mixed $intent Delivery intent.
+ * @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;
@@ -74,21 +74,26 @@ final class DeliveryPolicy {
* Missing keys default to safe values - empty array for `anchors`, null for
* `cutoff` and `intent`.
*
- * @param array<string, mixed> $data Decoded delivery_policy row.
+ * @param array<array-key, mixed> $data Decoded delivery_policy row.
*/
public static function from_array( array $data ): self {
- $anchors = is_array( $data['anchors'] ?? null ) ? $data['anchors'] : array();
- return new self(
- $anchors,
- $data['cutoff'] ?? null,
- $data['intent'] ?? null
- );
+ $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<string, mixed>>
+ * @return array<int, array<array-key, mixed>>
*/
public function get_anchors(): array {
return $this->anchors;
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
index 7deadfbb655..eb12a4cf6b6 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
@@ -41,7 +41,7 @@ final class PricingPolicy {
/**
* Recurring price adjustments, applied in array order.
*
- * @var array<int, array{type: string, value: float, starting_cycle: ?int}>
+ * @var array<int, array{type: string, value: float, starting_cycle?: int}>
*/
private $policies;
@@ -66,43 +66,58 @@ final class PricingPolicy {
/**
* Hydrate from the JSON-decoded `pricing_policy` column shape.
*
- * Missing top-level keys default to empty arrays. Numeric values are
- * normalized to float so a whole-number round-trip does not silently drift
- * from float to int and break type-strict comparisons downstream.
+ * Missing top-level keys default to empty arrays. Each policy and fee entry is
+ * normalized to exactly its documented shape: numeric values are coerced to
+ * float so a whole-number round-trip does not silently drift from float to int
+ * and break type-strict comparisons downstream, and missing keys take their
+ * documented defaults (a fee with no `taxable` key becomes `taxable => false`).
+ * The `tax_class` empty-string-vs-null distinction is preserved.
*
- * @param array<string, mixed> $data Decoded pricing_policy row.
+ * This method is the canonical normalization point for the `pricing_policy`
+ * column: keys outside the documented entry shapes are intentionally dropped,
+ * so a hydrate -> to_array round-trip will not carry them back. If extension
+ * data ever needs to survive the round-trip, add a reserved pass-through key
+ * to the shapes rather than accepting arbitrary keys.
+ *
+ * @param array<array-key, mixed> $data Decoded pricing_policy row.
*/
public static function from_array( array $data ): self {
$raw_policies = is_array( $data['policies'] ?? null ) ? $data['policies'] : array();
- $policies = array_values(
- array_filter(
- array_map(
- static function ( $entry ): ?array {
- if ( ! is_array( $entry ) ) {
- return null;
- }
- if ( isset( $entry['value'] ) && is_numeric( $entry['value'] ) ) {
- $entry['value'] = (float) $entry['value'];
- }
- return $entry;
- },
- $raw_policies
- ),
- static function ( $entry ): bool {
- return is_array( $entry );
- }
- )
- );
+ $policies = array();
+ foreach ( $raw_policies as $entry ) {
+ if ( ! is_array( $entry ) ) {
+ continue;
+ }
+ $policy = array(
+ 'type' => isset( $entry['type'] ) && is_scalar( $entry['type'] ) ? (string) $entry['type'] : '',
+ 'value' => isset( $entry['value'] ) && is_numeric( $entry['value'] ) ? (float) $entry['value'] : 0.0,
+ );
+ if ( isset( $entry['starting_cycle'] ) && is_numeric( $entry['starting_cycle'] ) ) {
+ $policy['starting_cycle'] = (int) $entry['starting_cycle'];
+ }
+ $policies[] = $policy;
+ }
- $fees = array_map(
- static function ( array $entry ): array {
- if ( isset( $entry['amount'] ) && is_numeric( $entry['amount'] ) ) {
- $entry['amount'] = (float) $entry['amount'];
- }
- return $entry;
- },
- $data['one_time_fees'] ?? array()
- );
+ $raw_fees = is_array( $data['one_time_fees'] ?? null ) ? $data['one_time_fees'] : array();
+ $fees = array();
+ foreach ( $raw_fees as $entry ) {
+ if ( ! is_array( $entry ) ) {
+ continue;
+ }
+ // Interpret taxable as a real boolean so a stored string like 'false'
+ // (truthy under !empty) does not flip a fee to taxable and change totals.
+ $taxable = false;
+ if ( array_key_exists( 'taxable', $entry ) ) {
+ $normalized_taxable = filter_var( $entry['taxable'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
+ $taxable = null !== $normalized_taxable ? $normalized_taxable : false;
+ }
+ $fees[] = array(
+ 'kind' => isset( $entry['kind'] ) && is_scalar( $entry['kind'] ) ? (string) $entry['kind'] : '',
+ 'amount' => isset( $entry['amount'] ) && is_numeric( $entry['amount'] ) ? (float) $entry['amount'] : 0.0,
+ 'taxable' => $taxable,
+ 'tax_class' => ( array_key_exists( 'tax_class', $entry ) && is_scalar( $entry['tax_class'] ) ) ? (string) $entry['tax_class'] : null,
+ );
+ }
return new self( $policies, $fees );
}
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 4ddff6fdb7b..e764542ecb1 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -535,6 +535,13 @@ final class RenewalEngine {
)
);
+ // This query does not paginate, so wc_get_orders() returns a plain list of
+ // orders. The guard narrows the declared WC_Order[]|stdClass return type and
+ // treats any unexpected non-array result as "no matching renewal".
+ if ( ! is_array( $orders ) ) {
+ return false;
+ }
+
foreach ( $orders as $order ) {
if ( ! $order instanceof WC_Order ) {
continue;
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 8a6d9f6ba5e..fbb129464bc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -14,6 +14,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
defined( 'ABSPATH' ) || exit;
@@ -22,6 +23,8 @@ defined( 'ABSPATH' ) || exit;
*/
final class ContractRepository {
+ use ScalarCoercion;
+
/**
* Address columns persisted to the addresses table.
*
@@ -225,13 +228,13 @@ final class ContractRepository {
SchemaInstaller::get_table_name( SchemaInstaller::TABLE_CONTRACT_ITEMS ),
array(
'contract_id' => $contract_id,
- 'item_name' => (string) ( $item['item_name'] ?? '' ),
- 'item_type' => (string) ( $item['item_type'] ?? 'line_item' ),
- 'product_id' => isset( $item['product_id'] ) ? (int) $item['product_id'] : null,
- 'variation_id' => isset( $item['variation_id'] ) ? (int) $item['variation_id'] : null,
- 'quantity' => (string) ( $item['quantity'] ?? '1' ),
- 'subtotal' => (string) ( $item['subtotal'] ?? '0' ),
- 'total' => (string) ( $item['total'] ?? '0' ),
+ 'item_name' => self::coerce_string( $item['item_name'] ?? null ),
+ 'item_type' => self::coerce_string( $item['item_type'] ?? null, 'line_item' ),
+ 'product_id' => isset( $item['product_id'] ) ? self::coerce_int( $item['product_id'] ) : null,
+ 'variation_id' => isset( $item['variation_id'] ) ? self::coerce_int( $item['variation_id'] ) : null,
+ 'quantity' => self::coerce_string( $item['quantity'] ?? null, '1' ),
+ 'subtotal' => self::coerce_string( $item['subtotal'] ?? null, '0' ),
+ 'total' => self::coerce_string( $item['total'] ?? null, '0' ),
'taxes' => isset( $item['taxes'] ) ? wp_json_encode( $item['taxes'] ) : null,
)
);
@@ -254,7 +257,7 @@ final class ContractRepository {
);
foreach ( self::ADDRESS_COLUMNS as $column ) {
- $record[ $column ] = isset( $address[ $column ] ) ? (string) $address[ $column ] : null;
+ $record[ $column ] = self::coerce_nullable_string( $address[ $column ] ?? null );
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -300,7 +303,14 @@ 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 is_array( $rows ) ? $rows : array();
+ $items = array();
+ foreach ( (array) $rows as $row ) {
+ if ( is_array( $row ) ) {
+ $items[] = $row;
+ }
+ }
+
+ return $items;
}
/**
@@ -318,8 +328,10 @@ final class ContractRepository {
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );
$by_type = array();
- foreach ( is_array( $rows ) ? $rows : array() as $row ) {
- $by_type[ (string) $row['address_type'] ] = $row;
+ foreach ( (array) $rows as $row ) {
+ if ( is_array( $row ) ) {
+ $by_type[ self::coerce_string( $row['address_type'] ?? null ) ] = $row;
+ }
}
return $by_type;
@@ -342,8 +354,10 @@ final class ContractRepository {
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );
$meta = array();
- foreach ( is_array( $rows ) ? $rows : array() as $row ) {
- $meta[ (string) $row['meta_key'] ] = (string) $row['meta_value'];
+ foreach ( (array) $rows as $row ) {
+ if ( is_array( $row ) ) {
+ $meta[ self::coerce_string( $row['meta_key'] ?? null ) ] = self::coerce_string( $row['meta_value'] ?? null );
+ }
}
return $meta;
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
index 451ba7b56f3..1fed245e80c 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Checkout/ContractFactoryTest.php
@@ -28,6 +28,10 @@ use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepositor
*/
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 {
$group_id = ( new PlanGroupRepository() )->insert(
PlanGroup::create( array( 'name' => 'Coffee club' ) )
@@ -73,7 +77,6 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$contract = ( new ContractFactory() )->create_from_order( $order, $plan );
- $this->assertInstanceOf( Contract::class, $contract );
$this->assertNotNull( $contract->get_id() );
$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
$this->assertSame( 'USD', $contract->get_currency() );
@@ -89,6 +92,7 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
// 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 ) );
}
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 5f441c518d7..393c8389a1f 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
@@ -86,6 +86,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
'meta_value' => (string) $contract_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
+ $this->assertIsArray( $orders );
return array_values(
array_filter(
@@ -122,16 +123,19 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order = $this->make_origin_order();
$contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
$engine = new RenewalEngine();
// No capability declared: scheduling is refused.
$this->assertFalse( $engine->schedule( $contract ) );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
// Declare it and the schedule sticks.
GatewayCapabilities::declare( self::GATEWAY, array( GatewayCapabilities::RECURRING ) );
$this->assertTrue( $engine->schedule( $contract ) );
- $this->assertTrue( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
}
public function test_schedule_replaces_existing_row(): void {
@@ -141,6 +145,9 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order = $this->make_origin_order();
$contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
$engine = new RenewalEngine();
$engine->schedule( $contract );
$engine->schedule( $contract );
@@ -149,7 +156,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$pending = as_get_scheduled_actions(
array(
'hook' => RenewalScheduler::HOOK,
- 'args' => array( $contract->get_id() ),
+ 'args' => array( $contract_id ),
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
@@ -164,22 +171,26 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order = $this->make_origin_order();
$contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
$engine = new RenewalEngine();
- $renewal_order = $engine->process_due( $contract->get_id() );
+ $renewal_order = $engine->process_due( $contract_id );
$this->assertInstanceOf( WC_Order::class, $renewal_order );
- $this->assertSame( (string) $contract->get_id(), $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
+ $this->assertSame( (string) $contract_id, $renewal_order->get_meta( OrderLinkage::META_CONTRACT_ID ) );
$this->assertSame( OrderLinkage::RELATION_RENEWAL, $renewal_order->get_meta( OrderLinkage::META_RELATION_TYPE ) );
$this->assertSame( '19.99', $renewal_order->get_total() );
// Contract advanced: cycle_count incremented, next bill date moved one month.
- $reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+ $reloaded = ( new ContractRepository() )->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( 1, $reloaded->get_cycle_count() );
$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
$this->assertSame( ContractStatus::ACTIVE, $reloaded->get_status() );
// Next cycle re-armed.
- $this->assertTrue( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
}
public function test_process_due_is_idempotent_for_a_retried_cycle(): void {
@@ -191,28 +202,33 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$contract = $this->make_contract( $plan_id, $order->get_id() );
$engine = new RenewalEngine();
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
// First fire creates the cycle-1 renewal and advances to cycle 1.
- $first = $engine->process_due( $contract->get_id() );
+ $first = $engine->process_due( $contract_id );
$this->assertInstanceOf( WC_Order::class, $first );
// Simulate an Action Scheduler retry of the same due action: rewind the
// persisted contract to its pre-advance state (cycle 0, original next
// date) so the retry attempts cycle 1 again - exactly what a duplicate
// dispatch would do before the advance committed.
- $rewound = $repo->find( $contract->get_id() );
+ $rewound = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $rewound );
$rewound->set_cycle_count( 0 );
$rewound->set_status( ContractStatus::ACTIVE );
$rewound->set_next_payment_gmt( '2026-02-15 00:00:00' );
$repo->update( $rewound );
- $retry = $engine->process_due( $contract->get_id() );
+ $retry = $engine->process_due( $contract_id );
// The per-cycle guard suppresses the retry: no second order, no advance.
$this->assertNull( $retry );
- $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract->get_id(), 1 ) );
+ $this->assertCount( 1, $this->renewal_orders_for_cycle( $contract_id, 1 ) );
- $reloaded = $repo->find( $contract->get_id() );
+ $reloaded = $repo->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( 0, $reloaded->get_cycle_count() );
}
@@ -223,14 +239,18 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order = $this->make_origin_order();
$contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
$engine = new RenewalEngine();
- $engine->process_due( $contract->get_id() );
+ $engine->process_due( $contract_id );
- $reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+ $reloaded = ( new ContractRepository() )->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( 1, $reloaded->get_cycle_count() );
$this->assertSame( ContractStatus::EXPIRED, $reloaded->get_status() );
$this->assertNull( $reloaded->get_next_payment_gmt() );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
public function test_process_due_skips_non_active_contract(): void {
@@ -242,9 +262,13 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$contract->set_status( ContractStatus::ON_HOLD );
( new ContractRepository() )->update( $contract );
- $this->assertNull( ( new RenewalEngine() )->process_due( $contract->get_id() ) );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $this->assertNull( ( new RenewalEngine() )->process_due( $contract_id ) );
- $reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+ $reloaded = ( new ContractRepository() )->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( 0, $reloaded->get_cycle_count() );
}
@@ -259,15 +283,19 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$order = $this->make_origin_order();
$contract = $this->make_contract( $plan_id, $order->get_id() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
$engine = new RenewalEngine();
$engine->schedule( $contract );
- $this->assertTrue( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertTrue( RenewalScheduler::is_scheduled( $contract_id ) );
$this->assertTrue( $engine->cancel( $contract ) );
- $reloaded = ( new ContractRepository() )->find( $contract->get_id() );
+ $reloaded = ( new ContractRepository() )->find( $contract_id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( ContractStatus::CANCELLED, $reloaded->get_status() );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
public function test_gateway_scheduled_contract_is_not_scheduled(): void {
@@ -290,7 +318,10 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
);
( new ContractRepository() )->insert( $contract );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
$this->assertFalse( ( new RenewalEngine() )->schedule( $contract ) );
- $this->assertFalse( RenewalScheduler::is_scheduled( $contract->get_id() ) );
+ $this->assertFalse( RenewalScheduler::is_scheduled( $contract_id ) );
}
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index c6b784ba86c..726bce0fec8 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
@@ -114,7 +114,9 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
)
);
- $this->assertNull( $repo->find( $id )->get_extension_slug() );
+ $found = $repo->find( $id );
+ $this->assertInstanceOf( Contract::class, $found );
+ $this->assertNull( $found->get_extension_slug() );
}
public function test_update_persists_scheduling_fields(): void {
@@ -122,6 +124,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$id = $repo->insert( $this->make_contract() );
$contract = $repo->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
$contract->set_status( ContractStatus::ON_HOLD );
$contract->set_next_payment_gmt( '2026-08-15 00:00:00' );
$contract->set_cycle_count( 3 );
@@ -130,6 +133,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertTrue( $repo->update( $contract ) );
$reloaded = $repo->find( $id );
+ $this->assertInstanceOf( Contract::class, $reloaded );
$this->assertSame( ContractStatus::ON_HOLD, $reloaded->get_status() );
$this->assertSame( '2026-08-15 00:00:00', $reloaded->get_next_payment_gmt() );
$this->assertSame( 3, $reloaded->get_cycle_count() );
@@ -141,6 +145,7 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$id = $repo->insert( $this->make_contract() );
$contract = $repo->find( $id );
+ $this->assertInstanceOf( Contract::class, $contract );
$this->assertCount( 1, $contract->get_items() );
// Re-create with a different set of items / meta and update.
@@ -170,7 +175,8 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {
$this->assertTrue( $repo->update( $mutated ) );
$reloaded = $repo->find( $id );
- $items = $reloaded->get_items();
+ $this->assertInstanceOf( Contract::class, $reloaded );
+ $items = $reloaded->get_items();
$this->assertCount( 1, $items );
$this->assertSame( 'Tea tin', $items[0]['item_name'] );
$this->assertSame( 'email', $reloaded->get_meta()['source_channel'] );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
index 9012669bd3b..5a5d0fb0758 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/PlanRepositoryTest.php
@@ -159,7 +159,9 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
$plan->set_name( 'After' );
$this->assertTrue( $repo->update( $plan ) );
- $this->assertSame( 'After', $repo->find( $id )->get_name() );
+ $updated = $repo->find( $id );
+ $this->assertInstanceOf( Plan::class, $updated );
+ $this->assertSame( 'After', $updated->get_name() );
}
public function test_delete_removes_the_row(): void {
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 e2b559f8c79..095562ef9d2 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -1,10 +1,11 @@
<?php
/**
- * Unit tests for Contract hydration invariants.
+ * Unit tests for Contract construction invariants.
*
* Confirms that Contract::from_storage() enforces the same boundary invariants
- * as the entity's setters, so a corrupted or migrated row cannot smuggle an
- * illegal value past hydration.
+ * as the entity's setters (a corrupted or migrated row cannot smuggle an illegal
+ * value past hydration), and that Contract::create() rejects missing or invalid
+ * required fields rather than coercing them to a silent default.
*
* @package Automattic\WooCommerce\SubscriptionsEngine
*/
@@ -19,7 +20,7 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract::from_storage
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract
*/
class ContractTest extends TestCase {
@@ -55,4 +56,78 @@ class ContractTest extends TestCase {
Contract::from_storage( $this->valid_row( -1 ) );
}
+
+ public function test_from_storage_rejects_non_integer_cycle_count(): void {
+ $row = $this->valid_row( 0 );
+ $row['cycle_count'] = '1.5';
+
+ $this->expectException( DomainException::class );
+
+ Contract::from_storage( $row );
+ }
+
+ public function test_from_storage_rejects_invalid_schedule_source(): void {
+ $row = $this->valid_row( 1 );
+ $row['schedule_source'] = 'bogus';
+
+ $this->expectException( DomainException::class );
+
+ Contract::from_storage( $row );
+ }
+
+ /**
+ * A minimal, valid set of Contract::create() arguments.
+ *
+ * @return array<string, mixed>
+ */
+ private function valid_create_args(): array {
+ return array(
+ 'customer_id' => 1,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-01-01 00:00:00',
+ );
+ }
+
+ public function test_create_succeeds_with_valid_required_fields(): void {
+ $contract = Contract::create( $this->valid_create_args() );
+
+ $this->assertSame( 1, $contract->get_customer_id() );
+ $this->assertSame( 'USD', $contract->get_currency() );
+ $this->assertSame( 2, $contract->get_selling_plan_id() );
+ $this->assertSame( 3, $contract->get_origin_order_id() );
+ }
+
+ public function test_create_allows_guest_customer_id_zero(): void {
+ $contract = Contract::create(
+ array_merge( $this->valid_create_args(), array( 'customer_id' => 0 ) )
+ );
+
+ $this->assertSame( 0, $contract->get_customer_id() );
+ }
+
+ /**
+ * @dataProvider provide_invalid_create_args
+ * @param array<string, mixed> $overrides Field overrides that should make create() reject the args.
+ */
+ public function test_create_rejects_invalid_required_fields( array $overrides ): void {
+ $this->expectException( DomainException::class );
+
+ Contract::create( array_merge( $this->valid_create_args(), $overrides ) );
+ }
+
+ /**
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function provide_invalid_create_args(): array {
+ return array(
+ 'missing customer_id' => array( array( 'customer_id' => null ) ),
+ 'zero selling_plan_id' => array( array( 'selling_plan_id' => 0 ) ),
+ 'non-numeric origin_order_id' => array( array( 'origin_order_id' => 'x' ) ),
+ 'missing currency' => array( array( 'currency' => null ) ),
+ 'empty currency' => array( array( 'currency' => '' ) ),
+ 'missing start_gmt' => array( array( 'start_gmt' => null ) ),
+ );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
index 5ff5e637f1b..5db48cfc449 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
@@ -138,4 +138,29 @@ class PlanTest extends TestCase {
$this->assertSame( 3, $storage['group_id'] );
$this->assertIsArray( $storage['billing_policy'] );
}
+
+ public function test_from_storage_rejects_corrupted_stored_pricing_policy(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ // A stored row whose pricing policy was tampered with outside engine flows
+ // (percentage over 100) must fail loud on hydration, not feed billing math.
+ Plan::from_storage(
+ array(
+ 'group_id' => 1,
+ 'name' => 'Corrupted',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ 'pricing_policy' => array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 150,
+ ),
+ ),
+ ),
+ )
+ );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
index 9c2800f93d0..c6179164aaf 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Gateway/GatewayCapabilitiesTest.php
@@ -41,14 +41,6 @@ class GatewayCapabilitiesTest extends TestCase {
);
}
- public function test_constants_match_their_string_values(): void {
- $this->assertSame( 'recurring', GatewayCapabilities::RECURRING );
- $this->assertSame( 'payment_method_change', GatewayCapabilities::PAYMENT_METHOD_CHANGE );
- $this->assertSame( 'amount_changes', GatewayCapabilities::AMOUNT_CHANGES );
- $this->assertSame( 'multiple_per_customer', GatewayCapabilities::MULTIPLE_PER_CUSTOMER );
- $this->assertSame( 'gateway_scheduled_renewals', GatewayCapabilities::GATEWAY_SCHEDULED_RENEWALS );
- }
-
public function test_declared_capabilities_resolve_true(): void {
GatewayCapabilities::declare( 'dummy', array( GatewayCapabilities::RECURRING ) );
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
index b69004ab61d..3d9dbcf826e 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
@@ -150,12 +150,14 @@ class BillingPolicyTest extends TestCase {
);
if ( null === $expected_exception_message ) {
- $this->assertInstanceOf( BillingPolicy::class, $policy );
$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(
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
index 42941fe210a..f976a8cfc86 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
@@ -96,4 +96,69 @@ class PricingPolicyTest extends TestCase {
$this->assertIsFloat( $policy->get_policies()[0]['value'] );
$this->assertIsFloat( $policy->get_one_time_fees()[0]['amount'] );
}
+
+ public function test_fees_normalize_to_typed_shape(): void {
+ $policy = PricingPolicy::from_array(
+ array(
+ 'one_time_fees' => array(
+ array(
+ 'kind' => 'setup',
+ 'amount' => 5,
+ ),
+ array(
+ 'kind' => 'service',
+ 'amount' => 7,
+ 'tax_class' => '',
+ ),
+ ),
+ )
+ );
+
+ $fees = $policy->get_one_time_fees();
+
+ // A fee without taxable/tax_class normalizes to taxable=false, tax_class=null.
+ $this->assertFalse( $fees[0]['taxable'] );
+ $this->assertNull( $fees[0]['tax_class'] );
+
+ // A supplied empty-string tax_class is preserved (not coerced to null),
+ // while a still-absent taxable normalizes to false.
+ $this->assertFalse( $fees[1]['taxable'] );
+ $this->assertSame( '', $fees[1]['tax_class'] );
+ }
+
+ /**
+ * @dataProvider provide_taxable_values
+ * @param mixed $supplied Raw taxable value as it might arrive from storage.
+ * @param bool $expected Expected normalized boolean.
+ */
+ public function test_taxable_is_interpreted_as_a_real_boolean( $supplied, bool $expected ): void {
+ $policy = PricingPolicy::from_array(
+ array(
+ 'one_time_fees' => array(
+ array(
+ 'kind' => 'setup',
+ 'amount' => 5,
+ 'taxable' => $supplied,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( $expected, $policy->get_one_time_fees()[0]['taxable'] );
+ }
+
+ /**
+ * @return array<string, array{0: mixed, 1: bool}>
+ */
+ public function provide_taxable_values(): array {
+ return array(
+ 'bool true' => array( true, true ),
+ 'bool false' => array( false, false ),
+ 'string true' => array( 'true', true ),
+ 'string false' => array( 'false', false ),
+ 'string one' => array( '1', true ),
+ 'string zero' => array( '0', false ),
+ 'unrecognized' => array( 'maybe', false ),
+ );
+ }
}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
index 0a1ac7bb7e9..fab1d541737 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/CapabilityRegistryTest.php
@@ -69,7 +69,9 @@ class CapabilityRegistryTest extends TestCase {
CapabilityRegistry::supports( 'dummy', GatewayCapabilities::RECURRING );
$calls = $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'];
+ $this->assertIsArray( $calls );
$this->assertCount( 1, $calls );
+ $this->assertIsArray( $calls[0] );
$this->assertSame( CapabilityRegistry::CAPABILITY_CHECK_FILTER, $calls[0]['hook'] );
// The pre-filter value is the steps-1-2 result: true for a declared cap.
$this->assertTrue( $calls[0]['value'] );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
index 9a39d990c05..64fac28b578 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Integration/Gateway/capability-registry-filter-stub.php
@@ -28,6 +28,9 @@ if ( ! function_exists( __NAMESPACE__ . '\\apply_filters' ) ) {
* @return mixed Configured override, or `$value` unchanged.
*/
function apply_filters( string $hook, $value, ...$args ) {
+ if ( ! isset( $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] ) || ! is_array( $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] ) ) {
+ $GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'] = array();
+ }
$GLOBALS['woocommerce_subscriptions_engine_test_apply_filters_calls'][] = array(
'hook' => $hook,
'value' => $value,