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,