Commit 1d31c573f77 for woocommerce

commit 1d31c573f777652d068337acdd892289fbf629d2
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Tue Oct 6 10:25:52 2026 +0200

    Store plan pricing as an opaque payload in the subscriptions engine (#69349)

    Store plan pricing as an opaque payload in the subscriptions engine

    - Remove the PricingPolicy value object, plan price calculation, the pricing type vocabulary, and BOGO bonus units on renewal orders
    - Store and return a plan's pricing_policy as given (object or null); consuming extensions own its semantics and price calculation
    - Fire woocommerce_subscriptions_engine_validate_plan on plan create and update so the owning extension can reject the write via a WP_Error collector; a throwing callback fails the write with a logged 500
    - Carry stored line quantities on renewal orders as is, including zero and fractional quantities

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/add-bogo-pricing-policy-type b/packages/php/woocommerce-subscriptions-engine/changelog/add-bogo-pricing-policy-type
deleted file mode 100644
index 311fb9f14af..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/changelog/add-bogo-pricing-policy-type
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: minor
-Type: add
-
-Add a value-less bogo pricing-policy type that grants a free unit of the same line item on in-scope cycles, materialized on renewal orders.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
index ff0591dce2b..569ffb4f68f 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -12,7 +12,6 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
 use InvalidArgumentException;
@@ -38,6 +37,8 @@ final class PlansController extends WP_REST_Controller {

 	private const DEFAULT_PER_PAGE = 20;

+	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';
+
 	/**
 	 * Plans repository.
 	 *
@@ -295,6 +296,12 @@ final class PlansController extends WP_REST_Controller {
 					'extension_slug' => $extension_slug,
 				)
 			);
+
+			$errors = $this->validate_with_owner( $plan, $extension_slug );
+			if ( is_wp_error( $errors ) ) {
+				return $this->as_bad_request( $errors );
+			}
+
 			$this->plan_repository->insert( $plan );
 		} catch ( Throwable $e ) {
 			return $this->invalid_error( $e->getMessage() );
@@ -363,6 +370,11 @@ final class PlansController extends WP_REST_Controller {
 				$plan->set_sort_order( ScalarCoercion::coerce_int( $request->get_param( 'sort_order' ) ) );
 			}

+			$errors = $this->validate_with_owner( $plan, $extension_slug );
+			if ( is_wp_error( $errors ) ) {
+				return $this->as_bad_request( $errors );
+			}
+
 			if ( ! $this->plan_repository->update( $plan ) ) {
 				return new WP_Error(
 					'woocommerce_subscriptions_engine_plan_update_failed',
@@ -427,8 +439,6 @@ final class PlansController extends WP_REST_Controller {
 	 * @return WP_REST_Response
 	 */
 	public function prepare_item_for_response( $item, $request ) {
-		$pricing = $item->get_pricing_policy();
-
 		$data = array(
 			'id'             => $item->get_id(),
 			'name'           => $item->get_name(),
@@ -438,7 +448,7 @@ final class PlansController extends WP_REST_Controller {
 			'sort_order'     => $item->get_sort_order(),
 			'extension_slug' => $item->get_extension_slug(),
 			'billing_policy' => $item->get_billing_policy()->to_array(),
-			'pricing_policy' => null !== $pricing ? $pricing->to_array() : null,
+			'pricing_policy' => $item->get_pricing_policy(),
 		);

 		$context = ScalarCoercion::coerce_string( $request->get_param( 'context' ), 'view' );
@@ -586,14 +596,15 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Build a pricing policy from a request param, preserving omitted existing keys.
+	 * Build the pricing payload from a request param. Provided top-level keys
+	 * replace existing ones; omitted keys keep their stored value.
 	 *
-	 * @param mixed              $value    Request value.
-	 * @param PricingPolicy|null $existing Existing policy.
-	 * @return PricingPolicy|null
-	 * @throws InvalidArgumentException If the param shape is invalid.
+	 * @param mixed                     $value    Request value.
+	 * @param array<string, mixed>|null $existing Existing payload.
+	 * @return array<string, mixed>|null
+	 * @throws InvalidArgumentException If the param is not an object or null.
 	 */
-	private function pricing_policy_from_param( $value, ?PricingPolicy $existing ): ?PricingPolicy {
+	private function pricing_policy_from_param( $value, ?array $existing ): ?array {
 		if ( null === $value ) {
 			return null;
 		}
@@ -603,15 +614,68 @@ final class PlansController extends WP_REST_Controller {
 		}

 		$value = $this->associative_array( $value, 'pricing_policy must be an object or null.' );
-		$data  = null !== $existing ? $existing->to_array() : array();
-		if ( array_key_exists( 'policies', $value ) ) {
-			$data['policies'] = $value['policies'];
+
+		return array_replace( $existing ?? array(), $value );
+	}
+
+	/**
+	 * Let the owning extension validate the plan before it is stored. A plan whose
+	 * owner registers no callback is stored without owner validation.
+	 *
+	 * @param Plan   $plan           Plan about to be written.
+	 * @param string $extension_slug Owning extension slug.
+	 * @return WP_Error|null Errors rejecting the write (500 if a callback threw), or null when valid.
+	 */
+	private function validate_with_owner( Plan $plan, string $extension_slug ): ?WP_Error {
+		$errors = new WP_Error();
+
+		try {
+			/**
+			 * Fires before a plan is written so the owning extension can validate it.
+			 *
+			 * Add errors to $errors to reject the write; act only on your own $extension_slug.
+			 * The plan is a copy: changes to it are not stored. Runs on create and update
+			 * (including status-only updates), not on reorder.
+			 *
+			 * @param WP_Error $errors         Error collector.
+			 * @param Plan     $plan           Plan about to be written; its id is null on create.
+			 * @param string   $extension_slug Owning extension slug.
+			 */
+			do_action( 'woocommerce_subscriptions_engine_validate_plan', $errors, clone $plan, $extension_slug );
+		} catch ( Throwable $e ) {
+			wc_get_logger()->error(
+				sprintf( 'PlansController: plan validation for extension "%s" (plan %s) threw: %s', $extension_slug, null === $plan->get_id() ? 'new' : (string) $plan->get_id(), $e->getMessage() ),
+				array(
+					'source'         => self::LOG_SOURCE,
+					'extension_slug' => $extension_slug,
+					'plan_id'        => $plan->get_id(),
+				)
+			);
+
+			return new WP_Error(
+				'woocommerce_subscriptions_engine_plan_validation_failed',
+				__( 'The plan could not be validated.', 'woocommerce-subscriptions-engine' ),
+				array( 'status' => 500 )
+			);
 		}
-		if ( array_key_exists( 'one_time_fees', $value ) ) {
-			$data['one_time_fees'] = $value['one_time_fees'];
+
+		return $errors->has_errors() ? $errors : null;
+	}
+
+	/**
+	 * Give each error code without a status a 400 status.
+	 *
+	 * @param WP_Error $errors Validation errors.
+	 */
+	private function as_bad_request( WP_Error $errors ): WP_Error {
+		foreach ( $errors->get_error_codes() as $code ) {
+			$data = $errors->get_error_data( $code );
+			if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
+				$errors->add_data( array_merge( is_array( $data ) ? $data : array(), array( 'status' => 400 ) ), $code );
+			}
 		}

-		return PricingPolicy::from_array( $data );
+		return $errors;
 	}

 	/**
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 2b84d145f5a..a27b58456ef 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -13,7 +13,6 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
 use InvalidArgumentException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\DeliveryPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;

 defined( 'ABSPATH' ) || exit;
@@ -36,8 +35,6 @@ final class Plan {

 	public const ALLOWED_STATUSES = array( self::STATUS_ACTIVE, self::STATUS_ARCHIVED );

-	public const ALLOWED_POLICY_TYPES = array( 'percentage', 'fixed_amount', 'price', 'bogo' );
-
 	/**
 	 * Plan id, or null before it is persisted.
 	 *
@@ -74,9 +71,9 @@ final class Plan {
 	private $delivery_policy;

 	/**
-	 * Optional pricing policy.
+	 * Optional pricing payload, owned and interpreted by the plan's extension.
 	 *
-	 * @var PricingPolicy|null
+	 * @var array<string, mixed>|null
 	 */
 	private $pricing_policy;

@@ -119,17 +116,17 @@ final class Plan {
 	/**
 	 * Use {@see self::create()} or {@see self::from_storage()}.
 	 *
-	 * @param int|null            $id              Plan id, or null before save.
-	 * @param string              $name            Display name.
-	 * @param string|null         $description     Optional description.
-	 * @param BillingPolicy       $billing_policy  Billing cadence.
-	 * @param DeliveryPolicy|null $delivery_policy Optional delivery policy.
-	 * @param PricingPolicy|null  $pricing_policy  Optional pricing policy.
-	 * @param string              $category        Plan category.
-	 * @param string              $status          Merchant lifecycle status.
-	 * @param int                 $sort_order      Manual display order.
-	 * @param string|null         $merchant_code   Optional stable external identifier.
-	 * @param string|null         $extension_slug  Owning extension slug.
+	 * @param int|null                  $id              Plan id, or null before save.
+	 * @param string                    $name            Display name.
+	 * @param string|null               $description     Optional description.
+	 * @param BillingPolicy             $billing_policy  Billing cadence.
+	 * @param DeliveryPolicy|null       $delivery_policy Optional delivery policy.
+	 * @param array<string, mixed>|null $pricing_policy  Optional pricing payload.
+	 * @param string                    $category        Plan category.
+	 * @param string                    $status          Merchant lifecycle status.
+	 * @param int                       $sort_order      Manual display order.
+	 * @param string|null               $merchant_code   Optional stable external identifier.
+	 * @param string|null               $extension_slug  Owning extension slug.
 	 */
 	private function __construct(
 		?int $id,
@@ -137,7 +134,7 @@ final class Plan {
 		?string $description,
 		BillingPolicy $billing_policy,
 		?DeliveryPolicy $delivery_policy,
-		?PricingPolicy $pricing_policy,
+		?array $pricing_policy,
 		string $category,
 		string $status,
 		int $sort_order,
@@ -163,16 +160,10 @@ final class Plan {
 	 * Build a new, unsaved plan.
 	 *
 	 * @param array<string, mixed> $args Plan attributes.
-	 * @throws InvalidArgumentException If pricing_policy entries fail validation.
+	 * @throws InvalidArgumentException If pricing_policy is not an object (string-keyed array) or null.
 	 */
 	public static function create( 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 );
-		}
+		$pricing_policy = self::assert_object_or_null( $args['pricing_policy'] ?? null );

 		$billing_policy = $args['billing_policy'] ?? null;
 		if ( ! $billing_policy instanceof BillingPolicy ) {
@@ -202,21 +193,14 @@ final class Plan {
 	/**
 	 * 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.
+	 * The pricing payload is checked only for shape (object or null); its
+	 * semantics belong to the owning extension.
 	 *
 	 * @param array<string, mixed> $row Decoded plan row.
-	 * @throws InvalidArgumentException If the stored pricing_policy fails validation.
+	 * @throws InvalidArgumentException If the stored pricing_policy is not an object.
 	 */
 	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 );
-		}
+		$pricing_policy = self::assert_object_or_null( $row['pricing_policy'] ?? null );

 		return new self(
 			isset( $row['id'] ) ? ScalarCoercion::coerce_int( $row['id'] ) : null,
@@ -314,23 +298,22 @@ final class Plan {
 	}

 	/**
-	 * Optional pricing policy.
+	 * Optional pricing payload, as stored.
+	 *
+	 * @return array<string, mixed>|null
 	 */
-	public function get_pricing_policy(): ?PricingPolicy {
+	public function get_pricing_policy(): ?array {
 		return $this->pricing_policy;
 	}

 	/**
-	 * Set the pricing policy.
+	 * Set the pricing payload.
 	 *
-	 * @param PricingPolicy|null $pricing_policy Pricing policy.
-	 * @throws InvalidArgumentException If pricing_policy entries fail validation.
+	 * @param array<string, mixed>|null $pricing_policy Pricing payload.
+	 * @throws InvalidArgumentException If pricing_policy is not an object (string-keyed array).
 	 */
-	public function set_pricing_policy( ?PricingPolicy $pricing_policy ): void {
-		if ( null !== $pricing_policy ) {
-			self::validate_pricing_policy( $pricing_policy );
-		}
-		$this->pricing_policy = $pricing_policy;
+	public function set_pricing_policy( ?array $pricing_policy ): void {
+		$this->pricing_policy = self::assert_object_or_null( $pricing_policy );
 	}

 	/**
@@ -397,44 +380,11 @@ final class Plan {
 		return $this->extension_slug;
 	}

-	/**
-	 * Apply this plan's pricing policy (if any) to a base price for the cycle.
-	 *
-	 * When no pricing policy is set, returns `$base_price` unchanged.
-	 *
-	 * @param float $base_price The product's base price for this cycle.
-	 * @param int   $cycle      1-indexed cycle number (1 = first billing cycle).
-	 */
-	public function calculate_price( float $base_price, int $cycle = 1 ): float {
-		if ( null === $this->pricing_policy ) {
-			return $base_price;
-		}
-
-		return $this->pricing_policy->calculate_price( $base_price, $cycle );
-	}
-
-	/**
-	 * Calculate the line total for this plan and cycle.
-	 *
-	 * When no pricing policy is set, this returns unit_price * quantity. Otherwise
-	 * the plan delegates to its pricing policy.
-	 *
-	 * @param float $unit_price The product's base unit price for this cycle.
-	 * @param float $quantity   Quantity on the line.
-	 * @param int   $cycle      1-indexed cycle number.
-	 */
-	public function calculate_line_total( float $unit_price, float $quantity, int $cycle = 1 ): float {
-		if ( null === $this->pricing_policy ) {
-			return max( 0.0, $unit_price * $quantity );
-		}
-
-		return $this->pricing_policy->calculate_line_total( $unit_price, $quantity, $cycle );
-	}
-
 	/**
 	 * Serialize to the storage column shape (excluding generated id/timestamps).
 	 *
-	 * Policy value objects are returned as arrays; the repository JSON-encodes them.
+	 * Policy value objects are returned as arrays and the pricing payload as
+	 * stored; the repository JSON-encodes them.
 	 *
 	 * @return array<string, mixed>
 	 */
@@ -444,7 +394,7 @@ final class Plan {
 			'description'     => $this->description,
 			'billing_policy'  => $this->billing_policy->to_array(),
 			'delivery_policy' => null !== $this->delivery_policy ? $this->delivery_policy->to_array() : null,
-			'pricing_policy'  => null !== $this->pricing_policy ? $this->pricing_policy->to_array() : null,
+			'pricing_policy'  => $this->pricing_policy,
 			'category'        => $this->category,
 			'status'          => $this->status,
 			'sort_order'      => $this->sort_order,
@@ -468,127 +418,31 @@ final class Plan {
 	}

 	/**
-	 * Validate every entry in a pricing policy's policies[] and one_time_fees[].
+	 * Accept a pricing payload only as an object (string-keyed array) or null.
+	 * An empty array is accepted (a JSON `{}` decodes to it).
 	 *
-	 * Rules:
-	 *  - policies[].type is one of percentage, fixed_amount, price, bogo.
-	 *  - policies[].value is numeric and non-negative; percentage is capped at 100;
-	 *    bogo must be exactly 0 (a value-less type - the benefit is an in-kind bonus
-	 *    unit, so a non-zero value is meaningless and rejected rather than silently
-	 *    stored).
-	 *  - policies[].duration_cycles is optional, integer, and positive.
-	 *  - one_time_fees[].amount is numeric and non-negative.
-	 *  - one_time_fees[].taxable is a bool.
-	 *  - one_time_fees[].tax_class is string or null (preserves '' != null).
-	 *  - one_time_fees[].kind is intentionally not whitelisted - consumers extend
-	 *    with namespaced kinds.
-	 *
-	 * @param PricingPolicy $pricing_policy Policy to validate.
-	 * @throws InvalidArgumentException With a message naming the offending entry index.
-	 */
-	private static function validate_pricing_policy( PricingPolicy $pricing_policy ): void {
-		foreach ( $pricing_policy->get_policies() as $index => $entry ) {
-			if ( ! is_array( $entry ) ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.policies[%d]: must be an array, got %s', (int) $index, gettype( $entry ) )
-				);
-			}
-
-			$type           = $entry['type'] ?? null;
-			$value          = $entry['value'] ?? null;
-			$starting_cycle = $entry['starting_cycle'] ?? null;
-			$duration       = $entry['duration_cycles'] ?? null;
-
-			if ( ! is_string( $type ) || ! in_array( $type, self::ALLOWED_POLICY_TYPES, true ) ) {
-				$shown = is_scalar( $type ) ? (string) $type : gettype( $type );
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.policies[%d]: invalid type %s', (int) $index, $shown )
-				);
-			}
-
-			if ( ! is_numeric( $value ) ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.policies[%d]: value must be numeric, got %s', (int) $index, gettype( $value ) )
-				);
-			}
-
-			$value = (float) $value;
-
-			if ( $value < 0 ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.policies[%d]: %s value must be non-negative, got %s', (int) $index, $type, $value )
-				);
-			}
-
-			if ( 'percentage' === $type && $value > 100 ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.policies[%d]: percentage must not exceed 100, got %s', (int) $index, $value )
-				);
-			}
-
-			if ( 'bogo' === $type && 0.0 !== $value ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.policies[%d]: bogo is value-less; value must be 0 or omitted, got %s', (int) $index, $value )
-				);
-			}
-
-			if ( null !== $starting_cycle ) {
-				if ( ! is_int( $starting_cycle ) ) {
-					throw new InvalidArgumentException(
-						sprintf( 'pricing_policy.policies[%d]: starting_cycle must be an integer, got %s', (int) $index, gettype( $starting_cycle ) )
-					);
-				}
-
-				if ( $starting_cycle < 1 ) {
-					throw new InvalidArgumentException(
-						sprintf( 'pricing_policy.policies[%d]: starting_cycle must be at least 1, got %d', (int) $index, $starting_cycle )
-					);
-				}
-			}
-
-			if ( null !== $duration ) {
-				if ( ! is_int( $duration ) ) {
-					throw new InvalidArgumentException(
-						sprintf( 'pricing_policy.policies[%d]: duration_cycles must be an integer, got %s', (int) $index, gettype( $duration ) )
-					);
-				}
-
-				if ( $duration < 1 ) {
-					throw new InvalidArgumentException(
-						sprintf( 'pricing_policy.policies[%d]: duration_cycles must be at least 1, got %d', (int) $index, $duration )
-					);
-				}
-			}
+	 * @param mixed $value Candidate payload.
+	 * @return array<string, mixed>|null
+	 * @throws InvalidArgumentException If the value is a list or not an array.
+	 */
+	private static function assert_object_or_null( $value ): ?array {
+		if ( null === $value ) {
+			return null;
 		}

-		foreach ( $pricing_policy->get_one_time_fees() as $index => $entry ) {
-			$amount    = $entry['amount'] ?? null;
-			$taxable   = $entry['taxable'] ?? null;
-			$tax_class = $entry['tax_class'] ?? null;
-
-			if ( ! is_numeric( $amount ) ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.one_time_fees[%d]: amount must be numeric, got %s', (int) $index, gettype( $amount ) )
-				);
-			}
-
-			if ( (float) $amount < 0 ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.one_time_fees[%d]: amount must be non-negative, got %s', (int) $index, $amount )
-				);
-			}
-
-			if ( ! is_bool( $taxable ) ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.one_time_fees[%d]: taxable must be a bool, got %s', (int) $index, gettype( $taxable ) )
-				);
-			}
+		$message = 'Plan: pricing_policy must be an object (string-keyed array) or null.';
+		if ( ! is_array( $value ) ) {
+			throw new InvalidArgumentException( $message );
+		}

-			if ( null !== $tax_class && ! is_string( $tax_class ) ) {
-				throw new InvalidArgumentException(
-					sprintf( 'pricing_policy.one_time_fees[%d]: tax_class must be string or null, got %s', (int) $index, gettype( $tax_class ) )
-				);
+		$out = array();
+		foreach ( $value as $key => $item ) {
+			if ( ! is_string( $key ) ) {
+				throw new InvalidArgumentException( $message );
 			}
+			$out[ $key ] = $item;
 		}
+
+		return $out;
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index 53bc30c7f4a..011d72467b7 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -17,7 +17,6 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;

 use DomainException;
-use InvalidArgumentException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;

 defined( 'ABSPATH' ) || exit;
@@ -128,29 +127,16 @@ final class PlanSnapshot {
 	}

 	/**
-	 * The frozen pricing policy, reconstructed from the snapshot payload.
+	 * The frozen pricing payload captured at signup, returned as captured. The
+	 * owning extension interprets it. Null when absent, explicitly null, or not an
+	 * array.
 	 *
-	 * Sourced from the `pricing_policy` entry captured at signup, NOT the live plan -
-	 * the same frozen-terms contract as {@see self::get_billing_policy()}. Returns
-	 * null when the payload carries no pricing policy (the plan had none at signup,
-	 * or the snapshot predates the key) or an unreadable one, so a caller degrades
-	 * to "no price adjustments" rather than fataling.
+	 * @return array<string, mixed>|null
 	 */
-	public function get_pricing_policy(): ?PricingPolicy {
+	public function get_pricing_policy(): ?array {
 		$policy = $this->data['pricing_policy'] ?? null;
-		if ( ! is_array( $policy ) ) {
-			return null;
-		}

-		try {
-			return PricingPolicy::from_array( self::string_keyed( $policy ) );
-		} catch ( InvalidArgumentException $e ) {
-			// A structurally-invalid stored policy degrades to "no price adjustments"
-			// rather than fataling the read (same fail-soft rationale as the billing
-			// accessor); snapshots this engine writes always carry a valid policy.
-			unset( $e );
-			return null;
-		}
+		return is_array( $policy ) ? self::string_keyed( $policy ) : null;
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
deleted file mode 100644
index 9bdf3c5ed6c..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
+++ /dev/null
@@ -1,323 +0,0 @@
-<?php
-/**
- * PricingPolicy - typed value object for a plan's recurring price adjustments
- * and one-time fees.
- *
- * Mirrors the `pricing_policy` JSON column shape. Shape:
- *   {
- *     policies: [
- *       { type: 'percentage'|'fixed_amount'|'price'|'bogo', value: float, starting_cycle?: int, duration_cycles?: int },
- *       ...
- *     ],
- *     one_time_fees: [
- *       { kind: string, amount: float, taxable: bool, tax_class: string|null },
- *       ...
- *     ]
- *   }
- *
- * `tax_class` empty-string semantics: `''` means the store's "Standard" class
- * (the implicit default), not "no class." `null` is reserved for a fee that is
- * genuinely untaxed. The two are not interchangeable - round-trip preserves
- * whichever was supplied.
- *
- * `bogo` entries are value-less: `value` is absent (normalized to `0.0`) or
- * explicitly `0`. The benefit is an in-kind bonus unit of the same line item
- * ({@see self::calculate_bonus_quantity()}), never a price change.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
-
-use InvalidArgumentException;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * PricingPolicy value object.
- *
- * Immutable. The plan column itself is nullable when neither policies nor fees
- * apply; the value object never represents that absence - it always holds two
- * arrays, possibly both empty.
- */
-final class PricingPolicy {
-
-	/**
-	 * Recurring price adjustments, applied in array order.
-	 *
-	 * @var array<int, array{type: string, value: float, starting_cycle?: int, duration_cycles?: int}>
-	 */
-	private $policies;
-
-	/**
-	 * One-time fees charged at contract creation.
-	 *
-	 * @var array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}>
-	 */
-	private $one_time_fees;
-
-	/**
-	 * Build a pricing policy.
-	 *
-	 * @param array<int, array{type: string, value: float, starting_cycle?: int, duration_cycles?: int}> $policies      Recurring price adjustments.
-	 * @param array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}>      $one_time_fees One-time fees.
-	 */
-	public function __construct( array $policies, array $one_time_fees ) {
-		$this->policies      = $policies;
-		$this->one_time_fees = $one_time_fees;
-	}
-
-	/**
-	 * Hydrate from the JSON-decoded `pricing_policy` column shape.
-	 *
-	 * 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.
-	 *
-	 * 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();
-		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'] ) ) {
-				$policy['starting_cycle'] = self::normalize_cycle( $entry['starting_cycle'], 'starting_cycle' );
-			}
-			if ( isset( $entry['duration_cycles'] ) ) {
-				$policy['duration_cycles'] = self::normalize_cycle( $entry['duration_cycles'], 'duration_cycles' );
-			}
-			$policies[] = $policy;
-		}
-
-		$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 );
-	}
-
-	/**
-	 * Recurring price adjustments. Each entry: `{type, value, starting_cycle?}`.
-	 *
-	 * @return array<int, array{type: string, value: float, starting_cycle?: int, duration_cycles?: int}>
-	 */
-	public function get_policies(): array {
-		return $this->policies;
-	}
-
-	/**
-	 * One-time fees charged at contract creation.
-	 *
-	 * @return array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}>
-	 */
-	public function get_one_time_fees(): array {
-		return $this->one_time_fees;
-	}
-
-	/**
-	 * Apply the recurring policy chain to a base price for the given cycle.
-	 *
-	 * Semantics:
-	 *  - Empty `policies` returns `$base_price` unchanged.
-	 *  - `type: 'percentage'`   -> `base_price * (100 - value) / 100`.
-	 *  - `type: 'fixed_amount'` -> `max(0, base_price - value)` (clamped at zero).
-	 *  - `type: 'price'`        -> `value` (replaces base price entirely).
-	 *  - `type: 'bogo'`         -> no price change (money-neutral; the benefit is an
-	 *    in-kind bonus unit granted at order materialization -
-	 *    {@see self::calculate_bonus_quantity()}).
-	 *  - `starting_cycle` gate: skip the entry when `$cycle < starting_cycle`.
-	 *    A missing `starting_cycle` means the entry applies to all cycles.
-	 *  - `duration_cycles` gate: skip the entry once the duration window ends.
-	 *  - Entries are applied in array order; later entries operate on the result.
-	 *
-	 * One-time fees are intentionally not applied here.
-	 *
-	 * @param float $base_price The product's base price for this cycle.
-	 * @param int   $cycle      1-indexed cycle number (1 = first billing cycle).
-	 */
-	public function calculate_price( float $base_price, int $cycle = 1 ): float {
-		$price = $base_price;
-
-		foreach ( $this->policies as $policy ) {
-			if ( ! $this->policy_applies_to_cycle( $policy, $cycle ) ) {
-				continue;
-			}
-
-			$type  = (string) ( $policy['type'] ?? '' );
-			$value = (float) ( $policy['value'] ?? 0 );
-
-			switch ( $type ) {
-				case 'percentage':
-					$price = $price * ( 100 - $value ) / 100;
-					break;
-				case 'fixed_amount':
-					$price = max( 0.0, $price - $value );
-					break;
-				case 'price':
-					$price = $value;
-					break;
-				case 'bogo':
-					// Money-neutral by design: the benefit is an in-kind bonus unit
-					// of the same line item, applied at order materialization via
-					// calculate_bonus_quantity() - never a price change.
-					break;
-				default:
-					break;
-			}
-		}
-
-		return $price;
-	}
-
-	/**
-	 * Apply the recurring policy chain to a line total for the given cycle.
-	 *
-	 * Line totals use the effective unit price produced by calculate_price().
-	 *
-	 * @param float $unit_price The product's base unit price for this cycle.
-	 * @param float $quantity   Quantity on the line.
-	 * @param int   $cycle      1-indexed cycle number.
-	 */
-	public function calculate_line_total( float $unit_price, float $quantity, int $cycle = 1 ): float {
-		$effective_unit_price = $this->calculate_price( $unit_price, $cycle );
-		return max( 0.0, $effective_unit_price * $quantity );
-	}
-
-	/**
-	 * The free units the chain's `bogo` entries grant for the given cycle.
-	 *
-	 * Each in-scope `bogo` entry grants one free unit per paid unit ("buy one, get
-	 * one" applied per unit), so a line of quantity q earns q bonus units per entry,
-	 * summed across entries. The same `starting_cycle`/`duration_cycles` gates as
-	 * the price chain apply ({@see self::policy_applies_to_cycle()}), so every-cycle
-	 * vs first-cycle BOGO is configured via scope, not a special case. Returns `0.0`
-	 * when no bogo entry is in scope or the paid quantity is not positive.
-	 *
-	 * The bonus is an in-kind benefit only: it never changes the price chain
-	 * ({@see self::calculate_price()} treats bogo as a no-op).
-	 *
-	 * @param float $paid_quantity Paid units on the line.
-	 * @param int   $cycle         1-indexed cycle number (1 = first billing cycle).
-	 */
-	public function calculate_bonus_quantity( float $paid_quantity, int $cycle = 1 ): float {
-		if ( $paid_quantity <= 0 ) {
-			return 0.0;
-		}
-
-		$bonus = 0.0;
-
-		foreach ( $this->policies as $policy ) {
-			if ( 'bogo' !== (string) ( $policy['type'] ?? '' ) ) {
-				continue;
-			}
-
-			if ( ! $this->policy_applies_to_cycle( $policy, $cycle ) ) {
-				continue;
-			}
-
-			$bonus += $paid_quantity;
-		}
-
-		return $bonus;
-	}
-
-	/**
-	 * Serialize back to the JSON column shape. Lossless round-trip with from_array().
-	 *
-	 * @return array<string, mixed>
-	 */
-	public function to_array(): array {
-		return array(
-			'policies'      => $this->policies,
-			'one_time_fees' => $this->one_time_fees,
-		);
-	}
-
-	/**
-	 * Whether a pricing policy entry applies to the requested cycle.
-	 *
-	 * @param array{starting_cycle?: int, duration_cycles?: int} $policy Policy entry.
-	 * @param int                                                $cycle  1-indexed cycle number.
-	 */
-	private function policy_applies_to_cycle( array $policy, int $cycle ): bool {
-		$starting_cycle = $policy['starting_cycle'] ?? 1;
-		if ( $cycle < $starting_cycle ) {
-			return false;
-		}
-
-		if ( isset( $policy['duration_cycles'] ) ) {
-			$last_cycle = $starting_cycle + $policy['duration_cycles'] - 1;
-			if ( $cycle > $last_cycle ) {
-				return false;
-			}
-		}
-
-		return true;
-	}
-
-	/**
-	 * Normalize a cycle gate value, which must be an integer value.
-	 * Throws exceptions for invalid values.
-	 *
-	 * @param mixed  $value Raw field value.
-	 * @param string $field Field name.
-	 * @throws InvalidArgumentException If the value is not a whole number.
-	 */
-	private static function normalize_cycle( $value, string $field ): int {
-		$int_value = null;
-
-		if ( is_int( $value ) ) {
-			$int_value = $value;
-		} elseif ( is_float( $value ) && floor( $value ) === $value ) {
-			$int_value = (int) $value;
-		} elseif ( is_string( $value ) ) {
-			$validated = filter_var( $value, FILTER_VALIDATE_INT );
-			if ( false !== $validated ) {
-				$int_value = $validated;
-			}
-		}
-
-		if ( null !== $int_value && $int_value >= 0 ) {
-			return $int_value;
-		}
-
-		throw new InvalidArgumentException(
-			sprintf( 'pricing_policy.policies[].%s must be a positive integer.', $field )
-		);
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
index 3436c68827e..6e03a723b5c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -168,24 +168,20 @@ final class ContractFactory {
 	/**
 	 * Build the typed plan snapshot for the origin cycle.
 	 *
-	 * The `pricing_policy` key freezes the discount rules the contract's cycles
-	 * bill under alongside the cadence; it is explicitly `null` when the plan has
-	 * none, so a reader can tell "no pricing policy at signup" apart from a
-	 * snapshot written before the key existed. The payload is schema-free, so the
-	 * additive key needs no format-version bump.
+	 * `pricing_policy` freezes the plan's pricing payload as is. It is an explicit
+	 * `null` when the plan has none, so a reader can tell "no pricing at signup"
+	 * from a snapshot written before the key existed.
 	 *
 	 * @param Plan $plan The plan whose terms to snapshot.
 	 */
 	private function build_plan_snapshot( Plan $plan ): PlanSnapshot {
-		$pricing_policy = $plan->get_pricing_policy();
-
 		return PlanSnapshot::from_array(
 			array(
 				'selling_plan_id' => $plan->get_id(),
 				'name'            => $plan->get_name(),
 				'category'        => $plan->get_category(),
 				'billing_policy'  => $plan->get_billing_policy()->to_array(),
-				'pricing_policy'  => null !== $pricing_policy ? $pricing_policy->to_array() : null,
+				'pricing_policy'  => $plan->get_pricing_policy(),
 			)
 		);
 	}
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 069f4eb8ff9..4b0a78b86a2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -31,7 +31,6 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;

 use DateTimeImmutable;
 use DateTimeZone;
-use InvalidArgumentException;
 use Throwable;
 use WC_Order;
 use WC_Order_Item_Product;
@@ -46,7 +45,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Renewal\RenewalCalculator;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Gateway\CapabilityRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -401,53 +399,6 @@ final class RenewalEngine {
 		return $plan instanceof Plan ? $plan->get_billing_policy() : null;
 	}

-	/**
-	 * Resolve the pricing policy the next cycle bills under - snapshot first, live
-	 * plan fallback, the same resolution order as {@see self::resolve_billing_policy()}.
-	 *
-	 * The snapshot's explicit `pricing_policy => null` means the frozen terms carry
-	 * no price adjustments, and is honored as null (a plan edited to gain a discount
-	 * after signup does not retroactively change the contract's terms). A snapshot
-	 * with NO `pricing_policy` key predates the key (or the contract has no
-	 * snapshot at all): those fall back to the live selling plan, as does an
-	 * unreadable stored policy. A null resolution means "no adjustments" - it never
-	 * blocks the renewal.
-	 *
-	 * @param Contract $contract The contract being renewed.
-	 * @return PricingPolicy|null The pricing policy, or null when none applies.
-	 */
-	private function resolve_pricing_policy( Contract $contract ): ?PricingPolicy {
-		$snapshot = $this->resolve_plan_snapshot( $contract );
-		if ( $snapshot instanceof PlanSnapshot ) {
-			$payload = $snapshot->to_array();
-			if ( array_key_exists( 'pricing_policy', $payload ) ) {
-				$stored = $payload['pricing_policy'];
-				if ( null === $stored ) {
-					// The frozen terms explicitly carry no pricing policy.
-					return null;
-				}
-				if ( is_array( $stored ) ) {
-					try {
-						return PricingPolicy::from_array( self::string_keyed( $stored ) );
-					} catch ( InvalidArgumentException $e ) {
-						// A corrupt stored policy must not crash the scheduled run; fall through
-						// to the live plan below so the renewal still resolves on current terms.
-						wc_get_logger()->warning(
-							sprintf( 'RenewalEngine: contract %d has an unreadable plan-snapshot pricing policy; falling back to the live plan. %s', (int) $contract->get_id(), $e->getMessage() ),
-							array(
-								'source'      => self::LOG_SOURCE,
-								'contract_id' => (int) $contract->get_id(),
-							)
-						);
-					}
-				}
-			}
-		}
-
-		$plan = $this->plans->find( $contract->get_selling_plan_id() );
-		return $plan instanceof Plan ? $plan->get_pricing_policy() : null;
-	}
-
 	/**
 	 * The contract's plan snapshot - the frozen terms the policy resolvers read.
 	 *
@@ -849,14 +800,6 @@ final class RenewalEngine {
 	 * renewal relation meta (contract id + chargeable number) so charge observers and the
 	 * order-to-cycle mapping can find it.
 	 *
-	 * BOGO materialization: when the contract's pricing policy grants bonus units for this
-	 * cycle ({@see PricingPolicy::calculate_bonus_quantity()}), each line's quantity is
-	 * raised to paid + bonus while its stored subtotal/total strings - and the cycle's
-	 * `expected_total`, applied below as the price authority - stay untouched: the benefit
-	 * is in-kind, never a price change. Renewal orders only: the ORIGIN order (cycle 1) is
-	 * built by the consumer's checkout, so first-cycle BOGO materialization on the initial
-	 * order is the consumer's job, not the engine's.
-	 *
 	 * Created draft-first: the order starts as `checkout-draft`, is linked onto the claimed
 	 * cycle (`order_id`), and only then becomes `pending`. A crash mid-way therefore leaves
 	 * either a linked draft the resume path promotes, or an unlinked draft that fires no emails
@@ -911,11 +854,6 @@ final class RenewalEngine {
 			$renewal_order->set_shipping_address( $addresses['shipping'] );
 		}

-		// The pricing policy the cycle bills under (frozen snapshot first, live plan
-		// fallback), for in-kind BOGO bonus units. Null resolves to "no bonus" - a
-		// missing policy never skips a renewal.
-		$pricing_policy = $this->resolve_pricing_policy( $contract );
-
 		// Only the contract's recurring line items - the origin order's one-time cart items are
 		// deliberately excluded so a mixed checkout cannot leak onto a renewal. A line for a
 		// since-deleted product makes WC_Order_Item_Product::set_product_id() throw; treat the
@@ -923,24 +861,13 @@ final class RenewalEngine {
 		// as a permanent failure that retries forever.
 		try {
 			foreach ( $contract->get_items() as $item ) {
-				$paid_quantity = max( 1, self::item_int( $item, 'quantity' ) );
-				$quantity      = $paid_quantity;
-				if ( null !== $pricing_policy ) {
-					// The quantity bump lands while the line is built, BEFORE add_item();
-					// set_total( $expected_total ) below stays the price authority, which
-					// BOGO never moves (money-neutral: the stored subtotal/total strings
-					// keep pricing the paid units only).
-					$bonus = $pricing_policy->calculate_bonus_quantity( (float) $paid_quantity, $count );
-					if ( $bonus > 0 ) {
-						$quantity = $paid_quantity + (int) round( $bonus );
-					}
-				}
-
 				$line = new WC_Order_Item_Product();
 				$line->set_name( self::item_string( $item, 'item_name' ) );
 				$line->set_product_id( self::item_int( $item, 'product_id' ) );
 				$line->set_variation_id( self::item_int( $item, 'variation_id' ) );
-				$line->set_quantity( $quantity );
+				// Stored quantity as is (zero and fractional included); the woocommerce_stock_amount
+				// filter decides precision.
+				$line->set_quantity( self::item_string( $item, 'quantity' ) ); // @phpstan-ignore argument.type (docblock-only int; fractional quantities are valid)
 				$line->set_subtotal( self::item_string( $item, 'subtotal' ) );
 				$line->set_total( self::item_string( $item, 'total' ) );
 				$renewal_order->add_item( $line );
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
index 22d52c54bfb..74a89f0916e 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
@@ -9,8 +9,11 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api\Rest;

+use Automattic\WooCommerce\SubscriptionsEngine\Api\Rest\PlansController;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use EngineIntegrationTestCase;
+use RuntimeException;
+use WP_Error;
 use WP_REST_Request;
 use WP_REST_Response;

@@ -41,6 +44,7 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 	}

 	public function tearDown(): void {
+		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
 		wp_set_current_user( 0 );
 		parent::tearDown();
 	}
@@ -142,9 +146,24 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			),
 			$billing_policy['trial_duration']
 		);
-		$this->assertSame( 'fixed_amount', $first_policy['type'] );
-		$this->assertSame( 3, $first_policy['duration_cycles'] );
-		$this->assertSame( 'setup', $first_fee['kind'] );
+		// Stored as sent: the engine coerces nothing inside the payload.
+		$this->assertSame(
+			array(
+				'type'            => 'fixed_amount',
+				'value'           => 2,
+				'duration_cycles' => 3,
+			),
+			$first_policy
+		);
+		$this->assertSame(
+			array(
+				'kind'      => 'setup',
+				'amount'    => 5,
+				'taxable'   => true,
+				'tax_class' => '',
+			),
+			$first_fee
+		);

 		$list = $this->request(
 			'GET',
@@ -160,48 +179,46 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertCount( 1, $this->response_data( $list ) );
 	}

-	public function test_create_round_trips_a_value_less_bogo_pricing_policy(): void {
+	public function test_create_round_trips_the_pricing_payload_opaquely(): void {
 		wp_set_current_user( $this->admin_id );

+		$pricing_policy = array(
+			'policies'   => array(
+				array(
+					'type'            => 'tiered',
+					'duration_cycles' => 1,
+				),
+				array( 'type' => 'bogo' ),
+			),
+			'custom_key' => array( 'nested' => 'kept' ),
+		);
+
 		$created = $this->request(
 			'POST',
 			self::BASE,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'name'           => 'Bogo monthly',
+				'name'           => 'Opaque monthly',
 				'billing_policy' => array(
 					'period'   => 'month',
 					'interval' => 1,
 				),
-				'pricing_policy' => array(
-					'policies' => array(
-						array(
-							'type'            => 'bogo',
-							'duration_cycles' => 1,
-						),
-					),
-				),
+				'pricing_policy' => $pricing_policy,
 			)
 		);

 		$this->assertSame( 201, $created->get_status() );
-		$created_data   = $this->response_data( $created );
-		$created_policy = $this->first_pricing_policy( $created_data );
-		$this->assertSame( 'bogo', $created_policy['type'] );
-		$this->assertSame( 0.0, $created_policy['value'], 'A value-less bogo entry normalizes to 0.0.' );
-		$this->assertSame( 1, $created_policy['duration_cycles'] );
+		$created_data = $this->response_data( $created );
+		$this->assertSame( $pricing_policy, $created_data['pricing_policy'], 'Unknown types and keys come back unchanged; no value is added.' );

 		// A fresh read round-trips the stored shape through the database.
 		$id      = $this->int_value( $created_data, 'id' );
 		$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
 		$this->assertSame( 200, $fetched->get_status() );
-		$fetched_policy = $this->first_pricing_policy( $this->response_data( $fetched ) );
-		$this->assertSame( 'bogo', $fetched_policy['type'] );
-		$this->assertSame( 0.0, $fetched_policy['value'] );
-		$this->assertSame( 1, $fetched_policy['duration_cycles'] );
+		$this->assertSame( $pricing_policy, $this->response_data( $fetched )['pricing_policy'] );
 	}

-	public function test_update_swaps_a_percentage_policy_to_bogo(): void {
+	public function test_update_replaces_provided_top_level_keys_and_keeps_omitted_ones(): void {
 		wp_set_current_user( $this->admin_id );

 		$created = $this->request(
@@ -215,12 +232,19 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 					'interval' => 1,
 				),
 				'pricing_policy' => array(
-					'policies' => array(
+					'policies'      => array(
 						array(
 							'type'  => 'percentage',
 							'value' => 10,
 						),
 					),
+					'one_time_fees' => array(
+						array(
+							'kind'   => 'setup',
+							'amount' => 5,
+						),
+					),
+					'custom_key'    => 'kept',
 				),
 			)
 		);
@@ -234,30 +258,36 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'extension_slug' => self::EXTENSION_SLUG,
 				'pricing_policy' => array(
 					'policies' => array(
-						array(
-							'type'  => 'bogo',
-							'value' => 0,
-						),
+						array( 'type' => 'bogo' ),
 					),
 				),
 			)
 		);
 		$this->assertSame( 200, $patched->get_status() );

-		// The swap persisted: a fresh read shows the bogo entry, not the percentage.
 		$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
 		$this->assertSame( 200, $fetched->get_status() );
-		$fetched_data   = $this->response_data( $fetched );
-		$fetched_policy = $this->first_pricing_policy( $fetched_data );
-		$this->assertSame( 'bogo', $fetched_policy['type'] );
-		$this->assertSame( 0.0, $fetched_policy['value'] );
-		$this->assertCount( 1, $this->array_value( $this->array_value( $fetched_data, 'pricing_policy' ), 'policies' ) );
+		$this->assertSame(
+			array(
+				'policies'      => array(
+					array( 'type' => 'bogo' ),
+				),
+				'one_time_fees' => array(
+					array(
+						'kind'   => 'setup',
+						'amount' => 5,
+					),
+				),
+				'custom_key'    => 'kept',
+			),
+			$this->response_data( $fetched )['pricing_policy']
+		);
 	}

-	public function test_bogo_with_a_non_zero_value_is_rejected(): void {
+	public function test_arbitrary_payload_values_are_stored_as_given(): void {
 		wp_set_current_user( $this->admin_id );

-		$invalid_pricing_policy = array(
+		$pricing_policy = array(
 			'policies' => array(
 				array(
 					'type'  => 'bogo',
@@ -271,33 +301,422 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 			self::BASE,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'name'           => 'Bad bogo',
+				'name'           => 'Any payload',
 				'billing_policy' => array(
 					'period'   => 'month',
 					'interval' => 1,
 				),
-				'pricing_policy' => $invalid_pricing_policy,
+				'pricing_policy' => $pricing_policy,
+			)
+		);
+
+		$this->assertSame( 201, $created->get_status(), 'Payload semantics belong to the owning extension.' );
+		$this->assertSame( $pricing_policy, $this->response_data( $created )['pricing_policy'] );
+	}
+
+	/**
+	 * @dataProvider provide_non_object_pricing_policies
+	 *
+	 * @param mixed  $invalid     Non-object pricing payload.
+	 * @param string $create_code Create error code: core schema validation rejects a scalar first.
+	 * @param string $patch_code  PATCH error code: the route has no arg schema, so the controller rejects.
+	 */
+	public function test_non_object_pricing_policy_is_rejected( $invalid, string $create_code, string $patch_code ): void {
+		wp_set_current_user( $this->admin_id );
+
+		$id = $this->create_plan( 'Patch target' );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Bad payload',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'pricing_policy' => $invalid,
 			)
 		);
 		$this->assertSame( 400, $created->get_status() );
-		$this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $this->response_data( $created )['code'] );
+		$this->assertSame( $create_code, $this->response_data( $created )['code'] );

-		$id      = $this->create_plan( 'Patch target' );
 		$patched = $this->request(
 			'PATCH',
 			self::BASE . '/' . $id,
 			array(
 				'extension_slug' => self::EXTENSION_SLUG,
-				'pricing_policy' => $invalid_pricing_policy,
+				'pricing_policy' => $invalid,
 			)
 		);
 		$this->assertSame( 400, $patched->get_status() );
-		$this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $this->response_data( $patched )['code'] );
+		$this->assertSame( $patch_code, $this->response_data( $patched )['code'] );

-		// The rejected PATCH left the plan untouched.
+		// The rejected writes left the plan untouched and created nothing.
 		$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
 		$this->assertSame( 200, $fetched->get_status() );
 		$this->assertNull( $this->response_data( $fetched )['pricing_policy'] );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$this->assertSame( '1', $list->get_headers()['X-WP-Total'] );
+	}
+
+	/**
+	 * @return array<string, array{0: mixed, 1: string, 2: string}>
+	 */
+	public function provide_non_object_pricing_policies(): array {
+		return array(
+			'list'   => array( array( array( 'type' => 'bogo' ) ), 'woocommerce_subscriptions_engine_invalid_plan', 'woocommerce_subscriptions_engine_invalid_plan' ),
+			'string' => array( 'bogo', 'rest_invalid_param', 'woocommerce_subscriptions_engine_invalid_plan' ),
+		);
+	}
+
+	public function test_validate_action_receives_errors_a_plan_copy_and_slug(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$calls = array();
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( $errors, $plan, $extension_slug ) use ( &$calls ): void {
+				self::assertInstanceOf( WP_Error::class, $errors );
+				self::assertFalse( $errors->has_errors() );
+				self::assertInstanceOf( Plan::class, $plan );
+				$calls[] = array( $plan->get_id(), $plan->get_name(), $plan->get_pricing_policy(), $extension_slug );
+			},
+			10,
+			3
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Validated',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'pricing_policy' => array(
+					'policies'   => array( array( 'type' => 'bogo' ) ),
+					'custom_key' => 'kept',
+				),
+			)
+		);
+		$this->assertSame( 201, $created->get_status() );
+		$id = $this->int_value( $this->response_data( $created ), 'id' );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Validated again',
+				'pricing_policy' => array( 'one_time_fees' => array( array( 'amount' => 5 ) ) ),
+			)
+		);
+		$this->assertSame( 200, $patched->get_status() );
+
+		$renamed = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Renamed only',
+			)
+		);
+		$this->assertSame( 200, $renamed->get_status() );
+
+		$merged_pricing = array(
+			'policies'      => array( array( 'type' => 'bogo' ) ),
+			'custom_key'    => 'kept',
+			'one_time_fees' => array( array( 'amount' => 5 ) ),
+		);
+
+		$this->assertSame(
+			array(
+				array(
+					null,
+					'Validated',
+					array(
+						'policies'   => array( array( 'type' => 'bogo' ) ),
+						'custom_key' => 'kept',
+					),
+					self::EXTENSION_SLUG,
+				),
+				array( $id, 'Validated again', $merged_pricing, self::EXTENSION_SLUG ),
+				array( $id, 'Renamed only', $merged_pricing, self::EXTENSION_SLUG ),
+			),
+			$calls,
+			'Create passes a plan with a null id; updates pass the stored plan with the request merged in.'
+		);
+	}
+
+	/**
+	 * @dataProvider provide_rejecting_errors
+	 *
+	 * @param array<string, mixed> $data            Error data the owner adds.
+	 * @param int                  $expected_status Expected response status.
+	 */
+	public function test_validate_action_error_rejects_create_and_update( array $data, int $expected_status ): void {
+		wp_set_current_user( $this->admin_id );
+		$id = $this->create_plan( 'Untouched' );
+
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ) use ( $data ): void {
+				$errors->add( 'owner_rejected', 'No.', $data );
+			}
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Rejected',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'pricing_policy' => array( 'policies' => array() ),
+			)
+		);
+		$this->assertSame( $expected_status, $created->get_status() );
+		$this->assertSame( 'owner_rejected', $this->response_data( $created )['code'] );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Changed',
+				'pricing_policy' => array( 'policies' => array() ),
+			)
+		);
+		$this->assertSame( $expected_status, $patched->get_status() );
+		$this->assertSame( 'owner_rejected', $this->response_data( $patched )['code'] );
+
+		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
+
+		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+		$this->assertSame( 'Untouched', $fetched['name'] );
+		$this->assertNull( $fetched['pricing_policy'] );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$this->assertSame( '1', $list->get_headers()['X-WP-Total'] );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, mixed>, 1: int}>
+	 */
+	public function provide_rejecting_errors(): array {
+		return array(
+			'without status' => array( array(), 400 ),
+			'with status'    => array( array( 'status' => 422 ), 422 ),
+		);
+	}
+
+	public function test_validate_action_errors_from_several_callbacks_accumulate(): void {
+		wp_set_current_user( $this->admin_id );
+
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'first_rejection', 'First.' );
+			}
+		);
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'second_rejection', 'Second.', array( 'status' => 422 ) );
+			},
+			20
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Rejected twice',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+			)
+		);
+
+		$this->assertSame( 400, $created->get_status() );
+		$data = $this->response_data( $created );
+		$this->assertSame( 'first_rejection', $data['code'] );
+		$this->assertSame(
+			array(
+				array(
+					'code'    => 'second_rejection',
+					'message' => 'Second.',
+					'data'    => array( 'status' => 422 ),
+				),
+			),
+			$data['additional_errors']
+		);
+	}
+
+	public function test_validate_action_cannot_change_the_stored_plan(): void {
+		wp_set_current_user( $this->admin_id );
+
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors, Plan $plan ): void {
+				$plan->set_name( 'Changed by callback' );
+				$plan->set_pricing_policy( array( 'policies' => array( array( 'type' => 'changed' ) ) ) );
+			},
+			10,
+			2
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'As sent',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'pricing_policy' => array( 'policies' => array( array( 'type' => 'raw' ) ) ),
+			)
+		);
+		$this->assertSame( 201, $created->get_status() );
+		$this->assertSame( 'As sent', $this->response_data( $created )['name'] );
+		$id = $this->int_value( $this->response_data( $created ), 'id' );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'As sent again',
+			)
+		);
+		$this->assertSame( 200, $patched->get_status() );
+
+		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
+
+		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+		$this->assertSame( 'As sent again', $fetched['name'] );
+		$this->assertSame( array( 'policies' => array( array( 'type' => 'raw' ) ) ), $fetched['pricing_policy'] );
+	}
+
+	public function test_throwing_validate_callback_fails_the_write_without_storing(): void {
+		wp_set_current_user( $this->admin_id );
+		$id = $this->create_plan( 'Untouched' );
+
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function (): void {
+				throw new RuntimeException( 'Internal detail.' );
+			}
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Thrown',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+			)
+		);
+		$this->assertSame( 500, $created->get_status() );
+		$created_data = $this->response_data( $created );
+		$this->assertSame( 'woocommerce_subscriptions_engine_plan_validation_failed', $created_data['code'] );
+		$this->assertIsString( $created_data['message'] );
+		$this->assertStringNotContainsString( 'Internal detail.', $created_data['message'] );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Changed',
+			)
+		);
+		$this->assertSame( 500, $patched->get_status() );
+		$this->assertSame( 'woocommerce_subscriptions_engine_plan_validation_failed', $this->response_data( $patched )['code'] );
+
+		remove_all_actions( 'woocommerce_subscriptions_engine_validate_plan' );
+
+		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+		$this->assertSame( 'Untouched', $fetched['name'] );
+
+		$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
+		$this->assertSame( '1', $list->get_headers()['X-WP-Total'] );
+	}
+
+	public function test_reorder_does_not_fire_the_validate_action(): void {
+		wp_set_current_user( $this->admin_id );
+		$first  = $this->create_plan( 'First' );
+		$second = $this->create_plan( 'Second' );
+
+		$calls = 0;
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function () use ( &$calls ): void {
+				++$calls;
+			}
+		);
+
+		$reordered = $this->request(
+			'POST',
+			self::BASE . '/reorder',
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'ids'            => array( $second, $first ),
+			)
+		);
+
+		$this->assertSame( 200, $reordered->get_status() );
+		$this->assertSame( 0, $calls );
+	}
+
+	public function test_patch_with_null_pricing_policy_clears_the_payload(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Priced',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+				'pricing_policy' => array( 'policies' => array( array( 'type' => 'raw' ) ) ),
+			)
+		);
+		$this->assertSame( 201, $created->get_status() );
+		$id = $this->int_value( $this->response_data( $created ), 'id' );
+
+		$patched = $this->request(
+			'PATCH',
+			self::BASE . '/' . $id,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'pricing_policy' => null,
+			)
+		);
+		$this->assertSame( 200, $patched->get_status() );
+		$this->assertNull( $this->response_data( $patched )['pricing_policy'] );
+
+		$fetched = $this->response_data( $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) ) );
+		$this->assertNull( $fetched['pricing_policy'] );
 	}

 	public function test_list_with_multiple_extension_slugs_returns_all_plans(): void {
@@ -642,16 +1061,6 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		return $ids;
 	}

-	/**
-	 * The first pricing-policy entry from a plan response.
-	 *
-	 * @param array<array-key, mixed> $data Plan response data.
-	 * @return array<array-key, mixed>
-	 */
-	private function first_pricing_policy( array $data ): array {
-		return $this->array_value( $this->array_value( $this->array_value( $data, 'pricing_policy' ), 'policies' ), 0 );
-	}
-
 	/**
 	 * Get a nested array value.
 	 *
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 2ed1a2e65e7..949175199f3 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
@@ -18,7 +18,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
@@ -231,27 +230,29 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox The origin cycle's plan snapshot freezes the pricing policy.
+	 * @testdox The origin cycle's plan snapshot freezes the pricing payload exactly as stored.
 	 */
 	public function test_plan_snapshot_round_trips_the_pricing_policy(): void {
+		$pricing_policy = array(
+			'policies'      => array(
+				array(
+					'type'            => 'bogo',
+					'duration_cycles' => 1,
+				),
+				array(
+					'type'  => 'tiered',
+					'value' => 10,
+				),
+			),
+			'one_time_fees' => array(),
+			'custom_key'    => 'kept',
+		);
+
 		$plan = Plan::create(
 			array(
 				'name'           => 'Discounted monthly',
 				'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array(
-								'type'            => 'bogo',
-								'duration_cycles' => 1,
-							),
-							array(
-								'type'  => 'percentage',
-								'value' => 10,
-							),
-						),
-					)
-				),
+				'pricing_policy' => $pricing_policy,
 				'category'       => Plan::DEFAULT_CATEGORY,
 				'extension_slug' => 'lite',
 			)
@@ -269,23 +270,10 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
 		$snapshot = $repo->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
 		$this->assertInstanceOf( PlanSnapshot::class, $snapshot );

-		// The stored payload carries the frozen pricing policy.
-		$payload = $snapshot->to_array();
-		$this->assertArrayHasKey( 'pricing_policy', $payload );
-		$this->assertIsArray( $payload['pricing_policy'] );
-
-		// The typed accessor reconstructs the frozen terms: bogo (first cycle only,
-		// value normalized to 0.0) plus the percentage entry, intact after the
-		// snapshot's DB round-trip.
-		$pricing = $snapshot->get_pricing_policy();
-		$this->assertInstanceOf( PricingPolicy::class, $pricing );
-		$policies = $pricing->get_policies();
-		$this->assertCount( 2, $policies );
-		$this->assertSame( 'bogo', $policies[0]['type'] );
-		$this->assertSame( 0.0, $policies[0]['value'] );
-		$this->assertSame( 1, $policies[0]['duration_cycles'] ?? null );
-		$this->assertSame( 'percentage', $policies[1]['type'] );
-		$this->assertSame( 10.0, $policies[1]['value'] );
+		// The engine freezes the payload uninterpreted: an unknown type and an extra
+		// key survive the DB round-trip, and no value is added to the bogo entry.
+		$this->assertSame( $pricing_policy, $snapshot->to_array()['pricing_policy'] );
+		$this->assertSame( $pricing_policy, $snapshot->get_pricing_policy() );
 	}

 	/**
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 7167c36c416..097cc2d318a 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
@@ -18,7 +18,6 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Gateway\GatewayCapabilities;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\ContractFactory;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Checkout\OrderLinkage;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Contracts\Cancellation;
@@ -1473,19 +1472,17 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @testdox the scheduled scan materializes an all-cycles bogo as bonus line quantity, money-neutral.
+	 * @testdox renewal order lines carry the paid quantity whatever the pricing payload says.
 	 */
-	public function test_scheduled_renewal_materializes_bogo_bonus_quantity(): void {
+	public function test_renewal_order_lines_carry_the_paid_quantity_whatever_the_pricing_payload(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

 		$contract    = $this->sign_up_contract_with_line_item(
 			self::GATEWAY_APPROVING,
-			PricingPolicy::from_array(
-				array(
-					'policies' => array(
-						array( 'type' => 'bogo' ),
-					),
-				)
+			array(
+				'policies' => array(
+					array( 'type' => 'bogo' ),
+				),
 			)
 		);
 		$contract_id = $contract->get_id();
@@ -1495,144 +1492,144 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );
 		$this->assertTrue( $renewal_order->is_paid() );

-		// The line carries paid + bonus units: 2 paid earn 2 free.
+		// The engine interprets no pricing vocabulary: paid quantity, stored amounts.
 		$items = array_values( $renewal_order->get_items() );
 		$this->assertCount( 1, $items );
 		$item = $items[0];
 		$this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
-		$this->assertSame( 4, $item->get_quantity() );
-
-		// Money-neutral: the line amounts still price the paid units only, and the
-		// order total is exactly the cycle's expected_total (the price authority).
+		$this->assertSame( 2, $item->get_quantity() );
 		$this->assertSame( 39.98, (float) $item->get_subtotal() );
 		$this->assertSame( 39.98, (float) $item->get_total() );
 		$this->assertSame( 39.98, (float) $renewal_order->get_total() );
-
-		$head = ( new ContractRepository() )->find_chain_head( $contract_id );
-		$this->assertInstanceOf( Cycle::class, $head );
-		$this->assertTrue( $head->get_status()->equals( CycleStatus::billed() ) );
-		$this->assertSame( '39.98000000', $head->get_expected_total() );
 	}

 	/**
-	 * @testdox a first-cycle-only bogo grants no bonus on the cycle-2 renewal order.
-	 *
-	 * `duration_cycles: 1` scopes the bogo benefit to cycle 1 (the origin order, whose
-	 * materialization is the consumer's checkout, not the engine's). The engine-built
-	 * cycle-2 renewal is outside the window: paid quantity only.
+	 * @testdox the renewal-order-created action receives the built order, linked on its cycle, and the contract.
 	 */
-	public function test_scheduled_renewal_grants_no_bonus_when_the_bogo_window_has_ended(): void {
+	public function test_renewal_order_created_action_receives_the_built_order_and_contract(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

-		$contract    = $this->sign_up_contract_with_line_item(
-			self::GATEWAY_APPROVING,
-			PricingPolicy::from_array(
-				array(
-					'policies' => array(
-						array(
-							'type'            => 'bogo',
-							'duration_cycles' => 1,
-						),
-					),
-				)
-			)
-		);
+		$contract    = $this->sign_up_contract_with_line_item( self::GATEWAY_APPROVING, null );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

+		$calls = array();
+		add_action(
+			RenewalEngine::RENEWAL_ORDER_CREATED_ACTION,
+			static function ( $order, $hooked_contract ) use ( &$calls ): void {
+				$head    = ( new ContractRepository() )->find_chain_head( (int) $hooked_contract->get_id() );
+				$calls[] = array(
+					'order'         => $order,
+					'contract'      => $hooked_contract,
+					'head_order_id' => $head instanceof Cycle ? $head->get_order_id() : null,
+				);
+			},
+			10,
+			2
+		);
+
 		$renewal_order = $this->run_scheduled_renewal( $contract_id );
 		$this->assertInstanceOf( WC_Order::class, $renewal_order );

-		$items = array_values( $renewal_order->get_items() );
+		$this->assertCount( 1, $calls, 'The action fires once for a new renewal order.' );
+		$this->assertInstanceOf( WC_Order::class, $calls[0]['order'] );
+		$this->assertSame( $renewal_order->get_id(), $calls[0]['order']->get_id() );
+		$this->assertInstanceOf( Contract::class, $calls[0]['contract'] );
+		$this->assertSame( $contract_id, $calls[0]['contract']->get_id() );
+
+		// The order is already linked on its cycle when listeners run.
+		$this->assertSame( $renewal_order->get_id(), $calls[0]['head_order_id'] );
+
+		$items = array_values( $calls[0]['order']->get_items() );
 		$this->assertCount( 1, $items );
-		$item = $items[0];
-		$this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
-		$this->assertSame( 2, $item->get_quantity(), 'No bonus outside the bogo window.' );
-		$this->assertSame( 39.98, (float) $renewal_order->get_total() );
+		$this->assertSame( 2, $items[0]->get_quantity() );
 	}

 	/**
-	 * @testdox the bogo bonus materializes from the contract's frozen snapshot even when the live plan is deleted.
+	 * @testdox the renewal-order-created action does not re-fire for a resumed order, and fires once for a fresh one.
 	 */
-	public function test_scheduled_renewal_materializes_bogo_from_the_snapshot_when_the_live_plan_is_deleted(): void {
+	public function test_renewal_order_created_action_is_not_refired_for_a_reused_order(): void {
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

-		$contract    = $this->sign_up_contract_with_line_item(
-			self::GATEWAY_APPROVING,
-			PricingPolicy::from_array(
-				array(
-					'policies' => array(
-						array( 'type' => 'bogo' ),
-					),
-				)
-			)
-		);
+		$contract    = $this->sign_up_contract( self::GATEWAY_APPROVING );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		// The live plan goes away; the contract's frozen snapshot carries the terms.
-		( new PlanRepository() )->delete( $contract->get_selling_plan_id() );
+		$repo     = new ContractRepository();
+		$previous = $repo->find_chain_head( $contract_id );
+		$this->assertInstanceOf( Cycle::class, $previous );
+		$this->make_pending_cycle_2( $contract_id, $previous, gmdate( 'Y-m-d H:i:s', time() - 60 ) );
+		$ghost = $this->make_ghost_renewal_order( $contract_id, 2, false );

-		$renewal_order = $this->run_scheduled_renewal( $contract_id );
-		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$fired = new \ArrayObject();
+		add_action(
+			RenewalEngine::RENEWAL_ORDER_CREATED_ACTION,
+			static function ( $order ) use ( $fired ): void {
+				$fired->append( $order->get_id() );
+			}
+		);

-		$items = array_values( $renewal_order->get_items() );
-		$this->assertCount( 1, $items );
-		$item = $items[0];
-		$this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
-		$this->assertSame( 4, $item->get_quantity(), 'The snapshot terms grant the bonus without the live plan.' );
+		$resumed = $this->run_scheduled_renewal( $contract_id );
+		$this->assertInstanceOf( WC_Order::class, $resumed );
+		$this->assertSame( $ghost->get_id(), $resumed->get_id() );
+		$this->assertSame( array(), $fired->getArrayCopy(), 'A reused order does not re-fire the action.' );
+
+		$fresh = $this->run_scheduled_renewal( $contract_id, new \DateTimeImmutable( '2026-03-16 00:00:00', new \DateTimeZone( 'UTC' ) ) );
+		$this->assertInstanceOf( WC_Order::class, $fresh );
+		$this->assertNotSame( $ghost->get_id(), $fresh->get_id() );
+		$this->assertSame( array( $fresh->get_id() ), $fired->getArrayCopy(), 'A freshly built order fires the action once.' );
 	}

 	/**
-	 * @testdox a discount added to the live plan after signup does not change a contract's frozen terms.
+	 * @testdox renewal order lines keep the stored quantity: $label.
+	 * @dataProvider provide_unclamped_quantities
 	 *
-	 * The snapshot records an explicit "no pricing policy" at signup; a bogo entry added
-	 * to the live plan later must not leak onto the contract's renewals (and the base
-	 * quantity regression holds: no pricing policy means quantities are untouched).
+	 * @param string    $label      Case label.
+	 * @param int|float $quantity   Origin line quantity.
+	 * @param bool      $fractional Whether a fractional stock-amount filter is active.
 	 */
-	public function test_scheduled_renewal_honors_the_snapshots_explicit_lack_of_a_pricing_policy(): void {
+	public function test_renewal_order_lines_keep_the_stored_quantity( string $label, $quantity, bool $fractional ): void {
+		if ( $fractional ) {
+			remove_filter( 'woocommerce_stock_amount', 'intval' );
+			add_filter( 'woocommerce_stock_amount', 'floatval' );
+		}
 		$this->approve_charges_for( self::GATEWAY_APPROVING );

-		$contract    = $this->sign_up_contract_with_line_item( self::GATEWAY_APPROVING, null );
+		$contract    = $this->sign_up_contract_with_line_item( self::GATEWAY_APPROVING, null, $quantity );
 		$contract_id = $contract->get_id();
 		$this->assertNotNull( $contract_id );

-		// The merchant later adds a bogo discount to the live plan.
-		$plans = new PlanRepository();
-		$plan  = $plans->find( $contract->get_selling_plan_id() );
-		$this->assertInstanceOf( Plan::class, $plan );
-		$plan->set_pricing_policy(
-			PricingPolicy::from_array(
-				array(
-					'policies' => array(
-						array( 'type' => 'bogo' ),
-					),
-				)
-			)
-		);
-		$this->assertTrue( $plans->update( $plan ) );
-
 		$renewal_order = $this->run_scheduled_renewal( $contract_id );
-		$this->assertInstanceOf( WC_Order::class, $renewal_order );
+		$this->assertInstanceOf( WC_Order::class, $renewal_order, $label );

 		$items = array_values( $renewal_order->get_items() );
 		$this->assertCount( 1, $items );
-		$item = $items[0];
-		$this->assertInstanceOf( \WC_Order_Item_Product::class, $item );
-		$this->assertSame( 2, $item->get_quantity(), 'Frozen terms: the later live-plan discount does not apply.' );
-		$this->assertSame( 39.98, (float) $renewal_order->get_total() );
+		$this->assertInstanceOf( \WC_Order_Item_Product::class, $items[0] );
+		$this->assertEquals( $quantity, $items[0]->get_quantity(), $label );
 	}

 	/**
-	 * Sign up a contract from an order carrying a real product line (quantity 2, USD
-	 * 39.98) on a monthly plan with the given pricing policy - the shape the BOGO
-	 * materialization tests read renewal line quantities from.
+	 * Quantities the engine must not floor or round.
 	 *
-	 * @param string             $gateway        Gateway id stamped on the order/contract.
-	 * @param PricingPolicy|null $pricing_policy The plan's pricing policy, or null for none.
+	 * @return array<string, array{0: string, 1: int|float, 2: bool}>
+	 */
+	public function provide_unclamped_quantities(): array {
+		return array(
+			'zero'       => array( 'zero', 0, false ),
+			'fractional' => array( 'fractional', 1.5, true ),
+		);
+	}
+
+	/**
+	 * Sign up a contract from an order carrying a real product line (quantity 2 by default,
+	 * USD 39.98) on a monthly plan with the given pricing payload.
+	 *
+	 * @param string                    $gateway        Gateway id stamped on the order/contract.
+	 * @param array<string, mixed>|null $pricing_policy The plan's pricing payload, or null for none.
+	 * @param int|float                 $quantity       Origin line quantity.
 	 * @return Contract The persisted contract with cycle 1 billed.
 	 */
-	private function sign_up_contract_with_line_item( string $gateway, ?PricingPolicy $pricing_policy ): Contract {
+	private function sign_up_contract_with_line_item( string $gateway, ?array $pricing_policy, $quantity = 2 ): Contract {
 		$plan = Plan::create(
 			array(
 				'name'           => 'Monthly',
@@ -1657,7 +1654,7 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
 		$line = new \WC_Order_Item_Product();
 		$line->set_name( 'Monthly Filters' );
 		$line->set_product_id( $product_id );
-		$line->set_quantity( 2 );
+		$line->set_quantity( $quantity ); // @phpstan-ignore argument.type (docblock-only int; fractional quantities are valid)
 		$line->set_subtotal( '39.98' );
 		$line->set_total( '39.98' );
 		$order->add_item( $line );
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 c66425483ff..36aed54bb14 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
@@ -12,7 +12,6 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integrati
 use EngineIntegrationTestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;

 /**
@@ -39,7 +38,16 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 	}

 	public function test_plan_round_trips_with_policies_and_extension_slug(): void {
-		$repo = new PlanRepository();
+		$repo           = new PlanRepository();
+		$pricing_policy = array(
+			'policies'   => array(
+				array(
+					'type'  => 'percentage',
+					'value' => 10,
+				),
+			),
+			'custom_key' => array( 'nested' => '1.50' ),
+		);

 		$plan = Plan::create(
 			array(
@@ -52,16 +60,7 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 						'max_cycles' => 12,
 					)
 				),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array(
-								'type'  => 'percentage',
-								'value' => 10,
-							),
-						),
-					)
-				),
+				'pricing_policy' => $pricing_policy,
 				'status'         => Plan::STATUS_ARCHIVED,
 				'sort_order'     => 4,
 				'extension_slug' => 'lite',
@@ -82,8 +81,7 @@ class PlanRepositoryTest extends EngineIntegrationTestCase {
 		$this->assertSame( 4, $fetched->get_sort_order() );
 		$this->assertSame( 'month', $fetched->get_billing_policy()->get_period() );
 		$this->assertSame( 12, $fetched->get_billing_policy()->get_max_cycles() );
-		$this->assertNotNull( $fetched->get_pricing_policy() );
-		$this->assertSame( 90.0, $fetched->calculate_price( 100.0 ) );
+		$this->assertSame( $pricing_policy, $fetched->get_pricing_policy() );
 	}

 	public function test_plan_without_optional_policies_round_trips(): void {
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 03572020c6e..83657b9af0b 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
@@ -1,6 +1,6 @@
 <?php
 /**
- * Unit tests for the Plan entity (pure-Core behavior: validation + pricing).
+ * Unit tests for the Plan entity (pure-Core behavior).
  *
  * @package Automattic\WooCommerce\SubscriptionsEngine
  */
@@ -13,7 +13,6 @@ use InvalidArgumentException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan
@@ -74,38 +73,6 @@ class PlanTest extends TestCase {
 		$this->assertNull( Plan::from_storage( $plan->to_storage() )->get_merchant_code() );
 	}

-	public function test_calculate_price_delegates_to_pricing_policy(): void {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Discounted',
-				'billing_policy' => $this->billing(),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array(
-								'type'  => 'percentage',
-								'value' => 20,
-							),
-						),
-					)
-				),
-			)
-		);
-
-		$this->assertSame( 80.0, $plan->calculate_price( 100.0 ) );
-	}
-
-	public function test_calculate_price_without_pricing_policy_returns_base(): void {
-		$plan = Plan::create(
-			array(
-				'name'           => 'Plain',
-				'billing_policy' => $this->billing(),
-			)
-		);
-
-		$this->assertSame( 42.0, $plan->calculate_price( 42.0 ) );
-	}
-
 	public function test_status_and_sort_order_are_mutable(): void {
 		$plan = Plan::create(
 			array(
@@ -134,184 +101,143 @@ class PlanTest extends TestCase {
 		);
 	}

-	public function test_invalid_pricing_policy_type_is_rejected(): void {
-		$this->expectException( InvalidArgumentException::class );
-
-		Plan::create(
+	public function test_to_storage_exposes_extension_slug_and_decoded_policies(): void {
+		$plan = Plan::create(
 			array(
-				'name'           => 'Bad',
+				'name'           => 'Owned',
 				'billing_policy' => $this->billing(),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array(
-								'type'  => 'mystery',
-								'value' => 1,
-							),
-						),
-					)
-				),
+				'status'         => Plan::STATUS_ARCHIVED,
+				'sort_order'     => 9,
+				'extension_slug' => 'lite',
+				'pricing_policy' => array( 'policies' => array() ),
 			)
 		);
-	}

-	public function test_percentage_over_one_hundred_is_rejected(): void {
-		$this->expectException( InvalidArgumentException::class );
+		$storage = $plan->to_storage();

-		Plan::create(
-			array(
-				'name'           => 'Too much',
-				'billing_policy' => $this->billing(),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array(
-								'type'  => 'percentage',
-								'value' => 150,
-							),
-						),
-					)
+		$this->assertSame( 'lite', $storage['extension_slug'] );
+		$this->assertSame( Plan::STATUS_ARCHIVED, $storage['status'] );
+		$this->assertSame( 9, $storage['sort_order'] );
+		$this->assertIsArray( $storage['billing_policy'] );
+		$this->assertSame( array( 'policies' => array() ), $storage['pricing_policy'] );
+	}
+
+	/**
+	 * An arbitrary extension payload, including vocabulary the engine does not know.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function arbitrary_payload(): array {
+		return array(
+			'policies'      => array(
+				array(
+					'type'  => 'tiered',
+					'value' => -5,
+					'tiers' => array( array( 'min' => 1 ), array( 'min' => 10 ) ),
 				),
-			)
+				array( 'type' => 'bogo' ),
+			),
+			'one_time_fees' => array(),
+			'custom_key'    => array( 'nested' => array( 'deep' => '1.50' ) ),
 		);
 	}

-	public function test_bogo_pricing_policy_is_accepted_value_less_and_with_zero_value(): void {
-		// Value-less entry: from_array() normalizes the missing value to 0.0.
-		$value_less = Plan::create(
+	public function test_pricing_payload_round_trips_opaquely_through_storage(): void {
+		$plan = Plan::create(
 			array(
-				'name'           => 'Bogo',
+				'name'           => 'Opaque',
 				'billing_policy' => $this->billing(),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array( 'type' => 'bogo' ),
-						),
-					)
-				),
+				'pricing_policy' => $this->arbitrary_payload(),
 			)
 		);

-		$pricing_policy = $value_less->get_pricing_policy();
-		$this->assertInstanceOf( PricingPolicy::class, $pricing_policy );
-		$this->assertSame( 0.0, $pricing_policy->get_policies()[0]['value'] );
-
-		// Explicit zero value is equally valid.
-		$explicit_zero = Plan::create(
-			array(
-				'name'           => 'Bogo zero',
-				'billing_policy' => $this->billing(),
-				'pricing_policy' => PricingPolicy::from_array(
-					array(
-						'policies' => array(
-							array(
-								'type'  => 'bogo',
-								'value' => 0,
-							),
-						),
-					)
-				),
-			)
-		);
+		$this->assertSame( $this->arbitrary_payload(), $plan->get_pricing_policy() );
+		$this->assertSame( $this->arbitrary_payload(), $plan->to_storage()['pricing_policy'] );

-		// A bogo entry never changes the price math.
-		$this->assertSame( 100.0, $explicit_zero->calculate_price( 100.0 ) );
-		$this->assertSame( 200.0, $explicit_zero->calculate_line_total( 100.0, 2.0 ) );
+		$hydrated = Plan::from_storage( $plan->to_storage() );

-		// And it survives the storage round-trip.
-		$hydrated         = Plan::from_storage( $value_less->to_storage() );
-		$hydrated_pricing = $hydrated->get_pricing_policy();
-		$this->assertInstanceOf( PricingPolicy::class, $hydrated_pricing );
-		$this->assertSame( 'bogo', $hydrated_pricing->get_policies()[0]['type'] );
+		$this->assertSame( $this->arbitrary_payload(), $hydrated->get_pricing_policy() );
 	}

-	private function bogo_with_value( float $value ): PricingPolicy {
-		return PricingPolicy::from_array(
+	public function test_set_pricing_policy_round_trips_and_clears(): void {
+		$plan = Plan::create(
 			array(
-				'policies' => array(
-					array(
-						'type'  => 'bogo',
-						'value' => $value,
-					),
-				),
+				'name'           => 'Mutating',
+				'billing_policy' => $this->billing(),
 			)
 		);
-	}

-	public function test_bogo_with_a_non_zero_value_is_rejected_on_create(): void {
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'bogo is value-less' );
+		$plan->set_pricing_policy( $this->arbitrary_payload() );
+		$this->assertSame( $this->arbitrary_payload(), $plan->get_pricing_policy() );

-		Plan::create(
+		$plan->set_pricing_policy( null );
+		$this->assertNull( $plan->get_pricing_policy() );
+	}
+
+	public function test_absent_pricing_payload_stays_null(): void {
+		$plan = Plan::create(
 			array(
-				'name'           => 'Bad bogo',
+				'name'           => 'Plain',
 				'billing_policy' => $this->billing(),
-				'pricing_policy' => $this->bogo_with_value( 5.0 ),
 			)
 		);
+
+		$this->assertNull( $plan->get_pricing_policy() );
+		$this->assertNull( $plan->to_storage()['pricing_policy'] );
+		$this->assertNull( Plan::from_storage( $plan->to_storage() )->get_pricing_policy() );
 	}

-	public function test_bogo_with_a_non_zero_value_is_rejected_on_set_pricing_policy(): void {
+	public function test_empty_pricing_payload_is_accepted(): void {
 		$plan = Plan::create(
 			array(
-				'name'           => 'Mutating',
+				'name'           => 'Empty',
 				'billing_policy' => $this->billing(),
+				'pricing_policy' => array(),
 			)
 		);

-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'bogo is value-less' );
-
-		$plan->set_pricing_policy( $this->bogo_with_value( 1.0 ) );
+		$this->assertSame( array(), $plan->get_pricing_policy() );
+		$this->assertSame( array(), Plan::from_storage( $plan->to_storage() )->get_pricing_policy() );
 	}

-	public function test_bogo_with_a_non_zero_value_is_rejected_on_from_storage(): void {
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( 'bogo is value-less' );
-
-		Plan::from_storage(
-			array(
-				'name'           => 'Tampered bogo',
-				'billing_policy' => array(
-					'period'   => 'month',
-					'interval' => 1,
-				),
-				'pricing_policy' => array(
-					'policies' => array(
-						array(
-							'type'  => 'bogo',
-							'value' => 5,
-						),
-					),
-				),
-			)
+	/**
+	 * @return array<string, array{0: mixed}>
+	 */
+	public function non_object_payloads(): array {
+		return array(
+			'list'   => array( array( array( 'type' => 'x' ) ) ),
+			'string' => array( 'percentage' ),
+			'int'    => array( 10 ),
 		);
 	}

-	public function test_to_storage_exposes_extension_slug_and_decoded_policies(): void {
-		$plan = Plan::create(
+	/**
+	 * @dataProvider non_object_payloads
+	 *
+	 * @param mixed $payload Non-object payload.
+	 */
+	public function test_create_rejects_a_non_object_pricing_payload( $payload ): void {
+		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'pricing_policy must be an object' );
+
+		Plan::create(
 			array(
-				'name'           => 'Owned',
+				'name'           => 'Bad',
 				'billing_policy' => $this->billing(),
-				'status'         => Plan::STATUS_ARCHIVED,
-				'sort_order'     => 9,
-				'extension_slug' => 'lite',
+				'pricing_policy' => $payload,
 			)
 		);
-
-		$storage = $plan->to_storage();
-
-		$this->assertSame( 'lite', $storage['extension_slug'] );
-		$this->assertSame( Plan::STATUS_ARCHIVED, $storage['status'] );
-		$this->assertSame( 9, $storage['sort_order'] );
-		$this->assertIsArray( $storage['billing_policy'] );
 	}

-	public function test_from_storage_rejects_corrupted_stored_pricing_policy(): void {
+	/**
+	 * @dataProvider non_object_payloads
+	 *
+	 * @param mixed $payload Non-object payload.
+	 */
+	public function test_from_storage_rejects_a_non_object_pricing_payload( $payload ): void {
 		$this->expectException( InvalidArgumentException::class );
+		$this->expectExceptionMessage( 'pricing_policy must be an object' );

-		// 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(
 				'name'           => 'Corrupted',
@@ -319,14 +245,7 @@ class PlanTest extends TestCase {
 					'period'   => 'month',
 					'interval' => 1,
 				),
-				'pricing_policy' => array(
-					'policies' => array(
-						array(
-							'type'  => 'percentage',
-							'value' => 150,
-						),
-					),
-				),
+				'pricing_policy' => $payload,
 			)
 		);
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
index e6892e8048d..231d29b2d59 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -145,4 +145,49 @@ class PlanSnapshotTest extends TestCase {

 		$this->assertNull( $snapshot->get_billing_policy() );
 	}
+
+	/**
+	 * @testdox get_pricing_policy returns the frozen payload exactly as captured.
+	 */
+	public function test_get_pricing_policy_returns_the_payload_as_captured(): void {
+		$payload  = array(
+			'policies'   => array(
+				array(
+					'type'  => 'tiered',
+					'value' => '7.5',
+				),
+			),
+			'custom_key' => array( 'nested' => true ),
+		);
+		$snapshot = PlanSnapshot::from_array( array( 'pricing_policy' => $payload ) );
+
+		$this->assertSame( $payload, $snapshot->get_pricing_policy() );
+	}
+
+	/**
+	 * @testdox get_pricing_policy is null when the key is absent.
+	 */
+	public function test_get_pricing_policy_is_null_when_absent(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );
+
+		$this->assertNull( $snapshot->get_pricing_policy() );
+	}
+
+	/**
+	 * @testdox get_pricing_policy is null when the key is explicitly null.
+	 */
+	public function test_get_pricing_policy_is_null_when_explicitly_null(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'pricing_policy' => null ) );
+
+		$this->assertNull( $snapshot->get_pricing_policy() );
+	}
+
+	/**
+	 * @testdox get_pricing_policy is null when the stored value is not an array.
+	 */
+	public function test_get_pricing_policy_is_null_for_a_non_array(): void {
+		$snapshot = PlanSnapshot::from_array( array( 'pricing_policy' => 'bogo' ) );
+
+		$this->assertNull( $snapshot->get_pricing_policy() );
+	}
 }
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
deleted file mode 100644
index f79b0ca5d90..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
+++ /dev/null
@@ -1,364 +0,0 @@
-<?php
-/**
- * Unit tests for PricingPolicy.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
-
-use InvalidArgumentException;
-use PHPUnit\Framework\TestCase;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PricingPolicy
- */
-class PricingPolicyTest extends TestCase {
-
-	public function test_empty_policy_returns_base_price(): void {
-		$policy = PricingPolicy::from_array( array() );
-
-		$this->assertSame( 25.0, $policy->calculate_price( 25.0 ) );
-		$this->assertSame( array(), $policy->get_policies() );
-		$this->assertSame( array(), $policy->get_one_time_fees() );
-	}
-
-	public function test_percentage_discount_applies(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'  => 'percentage',
-						'value' => 10,
-					),
-				),
-			)
-		);
-
-		$this->assertSame( 90.0, $policy->calculate_price( 100.0 ) );
-	}
-
-	public function test_line_total_uses_effective_unit_price_for_quantity(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'  => 'percentage',
-						'value' => 10,
-					),
-				),
-			)
-		);
-
-		$this->assertSame( 270.0, $policy->calculate_line_total( 100.0, 3.0 ) );
-	}
-
-	public function test_fixed_amount_is_clamped_at_zero(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'  => 'fixed_amount',
-						'value' => 30,
-					),
-				),
-			)
-		);
-
-		$this->assertSame( 0.0, $policy->calculate_price( 20.0 ) );
-	}
-
-	public function test_price_replaces_base_and_starting_cycle_gates(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'           => 'price',
-						'value'          => 5,
-						'starting_cycle' => 2,
-					),
-				),
-			)
-		);
-
-		// Cycle 1 is before the rule's starting cycle, so the base price stands.
-		$this->assertSame( 50.0, $policy->calculate_price( 50.0, 1 ) );
-		// Cycle 2 onward the rule fires and replaces the price.
-		$this->assertSame( 5.0, $policy->calculate_price( 50.0, 2 ) );
-	}
-
-	public function test_duration_cycles_limits_policy_window(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'            => 'percentage',
-						'value'           => 50,
-						'starting_cycle'  => 2,
-						'duration_cycles' => 2,
-					),
-				),
-			)
-		);
-
-		$this->assertSame( 100.0, $policy->calculate_price( 100.0, 1 ) );
-		$this->assertSame( 50.0, $policy->calculate_price( 100.0, 2 ) );
-		$this->assertSame( 50.0, $policy->calculate_price( 100.0, 3 ) );
-		$this->assertSame( 100.0, $policy->calculate_price( 100.0, 4 ) );
-	}
-
-	/**
-	 * @dataProvider provide_invalid_cycle_gate_values
-	 *
-	 * @param string $field Cycle gate field.
-	 * @param mixed  $value Invalid value.
-	 */
-	public function test_invalid_cycle_gate_values_are_rejected_by_pricing_policy( string $field, $value ): void {
-		$this->expectException( InvalidArgumentException::class );
-
-		PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'  => 'percentage',
-						'value' => 10,
-						$field  => $value,
-					),
-				),
-			)
-		);
-	}
-
-	/**
-	 * @return array<string, array{0: string, 1: mixed}>
-	 */
-	public function provide_invalid_cycle_gate_values(): array {
-		return array(
-			'fractional starting_cycle float'    => array( 'starting_cycle', 1.5 ),
-			'fractional starting_cycle string'   => array( 'starting_cycle', '1.5' ),
-			'non-numeric starting_cycle string'  => array( 'starting_cycle', 'soon' ),
-			'fractional duration_cycles float'   => array( 'duration_cycles', 1.5 ),
-			'fractional duration_cycles string'  => array( 'duration_cycles', '1.5' ),
-			'non-numeric duration_cycles string' => array( 'duration_cycles', 'forever' ),
-		);
-	}
-
-	public function test_bogo_entry_hydrates_value_less_and_leaves_prices_unchanged(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array( 'type' => 'bogo' ),
-				),
-			)
-		);
-
-		// A value-less bogo entry normalizes to value 0.0 and round-trips that shape.
-		$this->assertSame( 0.0, $policy->get_policies()[0]['value'] );
-		$this->assertSame(
-			array(
-				array(
-					'type'  => 'bogo',
-					'value' => 0.0,
-				),
-			),
-			$policy->to_array()['policies']
-		);
-
-		// Money-neutral: neither the unit price nor the line total moves.
-		$this->assertSame( 100.0, $policy->calculate_price( 100.0 ) );
-		$this->assertSame( 300.0, $policy->calculate_line_total( 100.0, 3.0 ) );
-	}
-
-	public function test_bogo_bonus_quantity_applies_to_all_cycles_without_scope_gates(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array( 'type' => 'bogo' ),
-				),
-			)
-		);
-
-		// One free unit per paid unit, on every cycle.
-		$this->assertSame( 2.0, $policy->calculate_bonus_quantity( 2.0, 1 ) );
-		$this->assertSame( 2.0, $policy->calculate_bonus_quantity( 2.0, 5 ) );
-		$this->assertSame( 1.0, $policy->calculate_bonus_quantity( 1.0, 3 ) );
-
-		// A non-positive paid quantity earns nothing.
-		$this->assertSame( 0.0, $policy->calculate_bonus_quantity( 0.0, 1 ) );
-	}
-
-	/**
-	 * @dataProvider provide_bogo_scope_windows
-	 *
-	 * @param array<string, int> $gates    Scope gate keys for the bogo entry.
-	 * @param int                $cycle    Cycle under test.
-	 * @param float              $expected Expected bonus for paid quantity 2.
-	 */
-	public function test_bogo_bonus_quantity_respects_the_cycle_scope_gates( array $gates, int $cycle, float $expected ): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array_merge( array( 'type' => 'bogo' ), $gates ),
-				),
-			)
-		);
-
-		$this->assertSame( $expected, $policy->calculate_bonus_quantity( 2.0, $cycle ) );
-	}
-
-	/**
-	 * @return array<string, array{0: array<string, int>, 1: int, 2: float}>
-	 */
-	public function provide_bogo_scope_windows(): array {
-		return array(
-			'first cycle only, cycle 1'    => array( array( 'duration_cycles' => 1 ), 1, 2.0 ),
-			'first cycle only, cycle 2'    => array( array( 'duration_cycles' => 1 ), 2, 0.0 ),
-			'three cycles, cycle 3'        => array( array( 'duration_cycles' => 3 ), 3, 2.0 ),
-			'three cycles, cycle 4'        => array( array( 'duration_cycles' => 3 ), 4, 0.0 ),
-			'starting cycle 2, cycle 1'    => array( array( 'starting_cycle' => 2 ), 1, 0.0 ),
-			'starting cycle 2, cycle 2'    => array( array( 'starting_cycle' => 2 ), 2, 2.0 ),
-			'window 2..3, cycle 3'         => array(
-				array(
-					'starting_cycle'  => 2,
-					'duration_cycles' => 2,
-				),
-				3,
-				2.0,
-			),
-			'window 2..3, cycle 4 (ended)' => array(
-				array(
-					'starting_cycle'  => 2,
-					'duration_cycles' => 2,
-				),
-				4,
-				0.0,
-			),
-		);
-	}
-
-	public function test_bonus_quantity_is_zero_without_a_bogo_entry(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'  => 'percentage',
-						'value' => 10,
-					),
-				),
-			)
-		);
-
-		$this->assertSame( 0.0, $policy->calculate_bonus_quantity( 5.0, 1 ) );
-		$this->assertSame( 0.0, PricingPolicy::from_array( array() )->calculate_bonus_quantity( 5.0, 1 ) );
-	}
-
-	public function test_bogo_composes_with_a_percentage_discount(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies' => array(
-					array(
-						'type'  => 'percentage',
-						'value' => 10,
-					),
-					array( 'type' => 'bogo' ),
-				),
-			)
-		);
-
-		// The percentage entry discounts the price AND the bogo entry grants the bonus.
-		$this->assertSame( 90.0, $policy->calculate_price( 100.0 ) );
-		$this->assertSame( 180.0, $policy->calculate_line_total( 100.0, 2.0 ) );
-		$this->assertSame( 2.0, $policy->calculate_bonus_quantity( 2.0, 1 ) );
-	}
-
-	public function test_whole_number_values_normalize_to_float(): void {
-		$policy = PricingPolicy::from_array(
-			array(
-				'policies'      => array(
-					array(
-						'type'  => 'percentage',
-						'value' => 10,
-					),
-				),
-				'one_time_fees' => array(
-					array(
-						'kind'    => 'enrollment',
-						'amount'  => 15,
-						'taxable' => true,
-					),
-				),
-			)
-		);
-
-		$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 ),
-		);
-	}
-}