Commit de7c83e61d6 for woocommerce
commit de7c83e61d6e4d008b581b5fe7a233c95a2c4aae
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Wed Jul 8 14:42:20 2026 +0200
Add a buy-one-get-one pricing-policy type to the subscriptions engine (#66379)
- Allow `bogo` in the plan pricing policy: value-less (a non-zero value is rejected) and money-neutral, since the benefit is an in-kind bonus unit rather than a price change
- Grant the bonus via `calculate_bonus_quantity()` (one free unit per paid unit, gated by the existing starting_cycle/duration_cycles scope); `calculate_price()` treats bogo as a no-op
- Freeze `pricing_policy` into the plan snapshot (explicit null when absent) and resolve it snapshot-first on renewals, so frozen terms win over later plan edits
- Materialize BOGO on renewal orders by raising each line's quantity to paid + bonus, while the stored line amounts and the cycle's expected_total stay the price authority
- Cover it with unit, integration, and REST round-trip tests (value-less round-trips with value normalized to 0.0; a non-zero bogo value is rejected with 400)
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
new file mode 100644
index 00000000000..311fb9f14af
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/add-bogo-pricing-policy-type
@@ -0,0 +1,4 @@
+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/Core/Entity/Plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
index 282b8a0b645..2b84d145f5a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -36,7 +36,7 @@ final class Plan {
public const ALLOWED_STATUSES = array( self::STATUS_ACTIVE, self::STATUS_ARCHIVED );
- public const ALLOWED_POLICY_TYPES = array( 'percentage', 'fixed_amount', 'price' );
+ public const ALLOWED_POLICY_TYPES = array( 'percentage', 'fixed_amount', 'price', 'bogo' );
/**
* Plan id, or null before it is persisted.
@@ -471,8 +471,11 @@ final class Plan {
* Validate every entry in a pricing policy's policies[] and one_time_fees[].
*
* Rules:
- * - policies[].type is one of percentage, fixed_amount, price.
- * - policies[].value is numeric and non-negative; percentage is capped at 100.
+ * - 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.
@@ -523,6 +526,12 @@ final class Plan {
);
}
+ 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(
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 c1490d59c05..53bc30c7f4a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -17,6 +17,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
use DomainException;
+use InvalidArgumentException;
use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ScalarCoercion;
defined( 'ABSPATH' ) || exit;
@@ -126,6 +127,32 @@ final class PlanSnapshot {
}
}
+ /**
+ * The frozen pricing policy, reconstructed from the snapshot payload.
+ *
+ * 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.
+ */
+ public function get_pricing_policy(): ?PricingPolicy {
+ $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;
+ }
+ }
+
/**
* The plan terms payload, in its original key order.
*
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
index 3399c4e9b53..9bdf3c5ed6c 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PricingPolicy.php
@@ -6,7 +6,7 @@
* Mirrors the `pricing_policy` JSON column shape. Shape:
* {
* policies: [
- * { type: 'percentage'|'fixed_amount'|'price', value: float, starting_cycle?: int, duration_cycles?: int },
+ * { type: 'percentage'|'fixed_amount'|'price'|'bogo', value: float, starting_cycle?: int, duration_cycles?: int },
* ...
* ],
* one_time_fees: [
@@ -20,6 +20,10 @@
* 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
*/
@@ -153,6 +157,9 @@ final class PricingPolicy {
* - `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.
@@ -184,6 +191,11 @@ final class PricingPolicy {
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;
}
@@ -206,6 +218,44 @@ final class PricingPolicy {
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().
*
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 f70b6c180ce..3436c68827e 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Checkout/ContractFactory.php
@@ -168,15 +168,24 @@ 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.
+ *
* @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,
)
);
}
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 198587eaf74..069f4eb8ff9 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Renewal/RenewalEngine.php
@@ -31,6 +31,7 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Renewal;
use DateTimeImmutable;
use DateTimeZone;
+use InvalidArgumentException;
use Throwable;
use WC_Order;
use WC_Order_Item_Product;
@@ -45,6 +46,7 @@ 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;
@@ -375,9 +377,7 @@ final class RenewalEngine {
* @return BillingPolicy|null The billing policy, or null when unresolvable.
*/
private function resolve_billing_policy( Contract $contract ): ?BillingPolicy {
- // The full contract reads already carry the snapshot; the store fetch is the
- // fallback for a contract built on a lean path (row-only reads).
- $snapshot = $contract->get_plan_snapshot() ?? $this->contracts->find_plan_snapshot( $contract->get_plan_snapshot_id() );
+ $snapshot = $this->resolve_plan_snapshot( $contract );
if ( $snapshot instanceof PlanSnapshot ) {
$payload = $snapshot->to_array();
if ( isset( $payload['billing_policy'] ) && is_array( $payload['billing_policy'] ) ) {
@@ -401,6 +401,66 @@ 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.
+ *
+ * The full contract reads already carry the snapshot; the store fetch is the
+ * fallback for a contract built on a lean path (row-only reads). Null when the
+ * contract carries no snapshot at all.
+ *
+ * @param Contract $contract The contract being renewed.
+ */
+ private function resolve_plan_snapshot( Contract $contract ): ?PlanSnapshot {
+ return $contract->get_plan_snapshot() ?? $this->contracts->find_plan_snapshot( $contract->get_plan_snapshot_id() );
+ }
+
/**
* Claim the head's successor cycle as the create-as-claim: resolve the cadence, compute the
* new `pending` cycle one period past the head, stamp a crash-recovery lease, and insert it.
@@ -789,6 +849,14 @@ 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
@@ -843,6 +911,11 @@ 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
@@ -850,11 +923,24 @@ 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( max( 1, self::item_int( $item, 'quantity' ) ) );
+ $line->set_quantity( $quantity );
$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 33d490837ae..22d52c54bfb 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
@@ -160,6 +160,146 @@ class PlansControllerTest extends EngineIntegrationTestCase {
$this->assertCount( 1, $this->response_data( $list ) );
}
+ public function test_create_round_trips_a_value_less_bogo_pricing_policy(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Bogo monthly',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ 'pricing_policy' => array(
+ 'policies' => array(
+ array(
+ 'type' => 'bogo',
+ 'duration_cycles' => 1,
+ ),
+ ),
+ ),
+ )
+ );
+
+ $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'] );
+
+ // 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'] );
+ }
+
+ public function test_update_swaps_a_percentage_policy_to_bogo(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Discounted monthly',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ 'pricing_policy' => array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 10,
+ ),
+ ),
+ ),
+ )
+ );
+ $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' => array(
+ 'policies' => array(
+ array(
+ 'type' => 'bogo',
+ 'value' => 0,
+ ),
+ ),
+ ),
+ )
+ );
+ $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' ) );
+ }
+
+ public function test_bogo_with_a_non_zero_value_is_rejected(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $invalid_pricing_policy = array(
+ 'policies' => array(
+ array(
+ 'type' => 'bogo',
+ 'value' => 5,
+ ),
+ ),
+ );
+
+ $created = $this->request(
+ 'POST',
+ self::BASE,
+ array(
+ 'extension_slug' => self::EXTENSION_SLUG,
+ 'name' => 'Bad bogo',
+ 'billing_policy' => array(
+ 'period' => 'month',
+ 'interval' => 1,
+ ),
+ 'pricing_policy' => $invalid_pricing_policy,
+ )
+ );
+ $this->assertSame( 400, $created->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $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,
+ )
+ );
+ $this->assertSame( 400, $patched->get_status() );
+ $this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $this->response_data( $patched )['code'] );
+
+ // The rejected PATCH left the plan untouched.
+ $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'] );
+ }
+
public function test_list_with_multiple_extension_slugs_returns_all_plans(): void {
wp_set_current_user( $this->admin_id );
@@ -502,6 +642,16 @@ 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 47e6f6f2452..2ed1a2e65e7 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
@@ -17,6 +17,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
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;
@@ -228,6 +230,87 @@ class ContractFactoryTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-12-01 00:00:00', $cycle->get_ends_at_gmt() );
}
+ /**
+ * @testdox The origin cycle's plan snapshot freezes the pricing policy.
+ */
+ public function test_plan_snapshot_round_trips_the_pricing_policy(): void {
+ $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,
+ ),
+ ),
+ )
+ ),
+ 'category' => Plan::DEFAULT_CATEGORY,
+ 'extension_slug' => 'lite',
+ )
+ );
+ ( new PlanRepository() )->insert( $plan );
+
+ $contract = ( new ContractFactory() )->create_from_order( $this->make_order(), $plan );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $repo = new ContractRepository();
+ $cycle = $repo->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $cycle );
+
+ $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'] );
+ }
+
+ /**
+ * @testdox The plan snapshot records an explicit null when the plan has no pricing policy.
+ */
+ public function test_plan_snapshot_is_null_safe_without_a_pricing_policy(): void {
+ $contract = ( new ContractFactory() )->create_from_order( $this->make_order(), $this->make_plan() );
+ $contract_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $repo = new ContractRepository();
+ $cycle = $repo->find_chain_head( $contract_id );
+ $this->assertInstanceOf( Cycle::class, $cycle );
+
+ $snapshot = $repo->find_plan_snapshot( $cycle->get_plan_snapshot_id() );
+ $this->assertInstanceOf( PlanSnapshot::class, $snapshot );
+
+ // Explicit null (not a missing key): the frozen terms deliberately record
+ // "no pricing policy at signup", distinguishable from a pre-key snapshot.
+ $payload = $snapshot->to_array();
+ $this->assertArrayHasKey( 'pricing_policy', $payload );
+ $this->assertNull( $payload['pricing_policy'] );
+ $this->assertNull( $snapshot->get_pricing_policy() );
+ }
+
/**
* @testdox An unsaved plan is rejected.
*/
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 1ce32294905..7167c36c416 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,6 +18,7 @@ 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;
@@ -1471,6 +1472,200 @@ class RenewalEngineTest extends EngineIntegrationTestCase {
$this->assertSame( '2026-03-15 00:00:00', $reloaded->get_next_payment_gmt() );
}
+ /**
+ * @testdox the scheduled scan materializes an all-cycles bogo as bonus line quantity, money-neutral.
+ */
+ public function test_scheduled_renewal_materializes_bogo_bonus_quantity(): 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_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $renewal_order = $this->run_scheduled_renewal( $contract_id );
+ $this->assertInstanceOf( WC_Order::class, $renewal_order );
+ $this->assertTrue( $renewal_order->is_paid() );
+
+ // The line carries paid + bonus units: 2 paid earn 2 free.
+ $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( 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.
+ */
+ public function test_scheduled_renewal_grants_no_bonus_when_the_bogo_window_has_ended(): 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_id = $contract->get_id();
+ $this->assertNotNull( $contract_id );
+
+ $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, $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() );
+ }
+
+ /**
+ * @testdox the bogo bonus materializes from the contract's frozen snapshot even when the live plan is deleted.
+ */
+ public function test_scheduled_renewal_materializes_bogo_from_the_snapshot_when_the_live_plan_is_deleted(): 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_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() );
+
+ $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, $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.' );
+ }
+
+ /**
+ * @testdox a discount added to the live plan after signup does not change a contract's frozen terms.
+ *
+ * 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).
+ */
+ public function test_scheduled_renewal_honors_the_snapshots_explicit_lack_of_a_pricing_policy(): void {
+ $this->approve_charges_for( self::GATEWAY_APPROVING );
+
+ $contract = $this->sign_up_contract_with_line_item( self::GATEWAY_APPROVING, null );
+ $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 );
+
+ $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() );
+ }
+
+ /**
+ * 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.
+ *
+ * @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 Contract The persisted contract with cycle 1 billed.
+ */
+ private function sign_up_contract_with_line_item( string $gateway, ?PricingPolicy $pricing_policy ): Contract {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Monthly',
+ 'billing_policy' => new BillingPolicy( 'month', 1, null, null, null ),
+ 'pricing_policy' => $pricing_policy,
+ 'category' => Plan::DEFAULT_CATEGORY,
+ 'extension_slug' => 'engine-tests',
+ )
+ );
+ ( new PlanRepository() )->insert( $plan );
+
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'Monthly Filters' );
+ $product->set_regular_price( '19.99' );
+ $product_id = (int) $product->save();
+
+ $order = new WC_Order();
+ $order->set_currency( 'USD' );
+ $order->set_payment_method( $gateway );
+ $order->set_total( '39.98' );
+ $order->set_date_paid( '2026-01-15 00:00:00' );
+ $line = new \WC_Order_Item_Product();
+ $line->set_name( 'Monthly Filters' );
+ $line->set_product_id( $product_id );
+ $line->set_quantity( 2 );
+ $line->set_subtotal( '39.98' );
+ $line->set_total( '39.98' );
+ $order->add_item( $line );
+ $order->save();
+
+ return ( new ContractFactory() )->create_from_order( $order, $plan );
+ }
+
/**
* Append a pending cycle 2 (no tagged renewal order) with the given crash-recovery
* lease, so the create-as-claim collides on it and the reclaim-vs-skip path is exercised.
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 b97611a918b..03572020c6e 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
@@ -176,6 +176,118 @@ class PlanTest extends TestCase {
);
}
+ 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(
+ array(
+ 'name' => 'Bogo',
+ 'billing_policy' => $this->billing(),
+ 'pricing_policy' => PricingPolicy::from_array(
+ array(
+ 'policies' => array(
+ array( 'type' => 'bogo' ),
+ ),
+ )
+ ),
+ )
+ );
+
+ $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,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+
+ // 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 ) );
+
+ // 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'] );
+ }
+
+ private function bogo_with_value( float $value ): PricingPolicy {
+ return PricingPolicy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'bogo',
+ 'value' => $value,
+ ),
+ ),
+ )
+ );
+ }
+
+ 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::create(
+ array(
+ 'name' => 'Bad bogo',
+ 'billing_policy' => $this->billing(),
+ 'pricing_policy' => $this->bogo_with_value( 5.0 ),
+ )
+ );
+ }
+
+ public function test_bogo_with_a_non_zero_value_is_rejected_on_set_pricing_policy(): void {
+ $plan = Plan::create(
+ array(
+ 'name' => 'Mutating',
+ 'billing_policy' => $this->billing(),
+ )
+ );
+
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'bogo is value-less' );
+
+ $plan->set_pricing_policy( $this->bogo_with_value( 1.0 ) );
+ }
+
+ 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,
+ ),
+ ),
+ ),
+ )
+ );
+ }
+
public function test_to_storage_exposes_extension_slug_and_decoded_policies(): void {
$plan = Plan::create(
array(
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
index 5523974ac71..f79b0ca5d90 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PricingPolicyTest.php
@@ -146,6 +146,134 @@ class PricingPolicyTest extends TestCase {
);
}
+ 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(