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(