From edc07042d81aa44e552feb3460755a96d0a1c66f Mon Sep 17 00:00:00 2001 From: Usman Khan Date: Mon, 24 Aug 2026 19:20:05 +0500 Subject: [PATCH] Honour discount conditions in BuyXGetY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractDiscountType::checkDiscountConditions() enforces a discount's minimum spend, customer restrictions and per-user usage limit. AmountOff::apply() guards on it before doing anything; BuyXGetY::apply() never calls it, so a BuyXGetY discount applies whenever its quantity condition is met, whatever those fields say. Dates, max_uses, the coupon and the channel are already enforced by the query in DiscountManager::getDiscounts(), so those continue to hold. What is only checked in checkDiscountConditions() is ignored: a "spend £50, get one free" promotion fires on a £20 cart, and a discount restricted to named customers applies to everyone. Adds the same guard AmountOff uses. Tests cover a cart below the advertised minimum spend and a discount restricted to a customer the cart does not belong to; both fail on 1.x with "Failed asserting that 1000 matches expected 0". A third asserts the discount still applies when the minimum is met, so the guard is not simply switching it off. Core suite: 556 passing before, 559 after. --- packages/core/src/DiscountTypes/BuyXGetY.php | 4 + .../DiscountTypes/BuyXGetYConditionsTest.php | 136 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php diff --git a/packages/core/src/DiscountTypes/BuyXGetY.php b/packages/core/src/DiscountTypes/BuyXGetY.php index 32ceb017cd..968b8290ba 100644 --- a/packages/core/src/DiscountTypes/BuyXGetY.php +++ b/packages/core/src/DiscountTypes/BuyXGetY.php @@ -51,6 +51,10 @@ public function getRewardQuantity($linesQuantity, $minQty, $rewardQty, $maxRewar */ public function apply(CartContract $cart): CartContract { + if (! $this->checkDiscountConditions($cart)) { + return $cart; + } + $data = $this->discount->data; $minQty = $data['min_qty'] ?? null; diff --git a/tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php b/tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php new file mode 100644 index 0000000000..8454b2354d --- /dev/null +++ b/tests/core/Unit/DiscountTypes/BuyXGetYConditionsTest.php @@ -0,0 +1,136 @@ +create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP', 'default' => true]); + + $conditionProduct = Product::factory()->create(); + $rewardProduct = Product::factory()->create(); + + $condition = ProductVariant::factory()->create(['product_id' => $conditionProduct->id]); + $reward = ProductVariant::factory()->create(['product_id' => $rewardProduct->id]); + + foreach ([$condition, $reward] as $variant) { + Price::factory()->create([ + 'price' => 1000, // £10 + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $variant->getMorphClass(), + 'priceable_id' => $variant->id, + ]); + } + + $discount = Discount::factory()->create(array_merge([ + 'type' => BuyXGetY::class, + 'name' => 'Buy one get one', + 'starts_at' => now()->subDay(), + 'data' => array_merge([ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => false, + ], $discountData), + ], $discountAttributes)); + + $discount->channels()->attach([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subDay()], + ]); + + $discount->customerGroups()->attach([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subDay()], + ]); + + $discount->discountables()->create([ + 'discountable_type' => $conditionProduct->getMorphClass(), + 'discountable_id' => $conditionProduct->id, + 'type' => 'condition', + ]); + + $discount->discountables()->create([ + 'discountable_type' => $rewardProduct->getMorphClass(), + 'discountable_id' => $rewardProduct->id, + 'type' => 'reward', + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + 'coupon_code' => null, + ]); + + // £10 of the condition product and £10 of the reward product: £20 total. + $cart->lines()->create([ + 'purchasable_type' => $condition->getMorphClass(), + 'purchasable_id' => $condition->id, + 'quantity' => 1, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $reward->getMorphClass(), + 'purchasable_id' => $reward->id, + 'quantity' => 1, + ]); + + return $cart->refresh(); +} + +test('is not applied when the cart is below the minimum spend', function () { + // £50 minimum against a £20 cart. + $cart = buyXGetYConditionCart([ + 'min_prices' => ['GBP' => 5000], + ]); + + $cart->calculate(); + + expect($cart->discountTotal->value)->toEqual(0); +}); + +test('is applied when the cart meets the minimum spend', function () { + // £10 minimum against a £20 cart. + $cart = buyXGetYConditionCart([ + 'min_prices' => ['GBP' => 1000], + ]); + + $cart->calculate(); + + expect($cart->discountTotal->value)->toBeGreaterThan(0); +}); + +test('is not applied when the cart customer is not on the discount', function () { + $cart = buyXGetYConditionCart([]); + + // Restrict the discount to a customer this cart does not belong to. + $discount = Discount::first(); + $discount->customers()->attach(Customer::factory()->create()->id); + + $cart->refresh()->calculate(); + + expect($cart->discountTotal->value)->toEqual(0); +});