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 ),
- );
- }
-}