diff --git a/packages/core/src/DiscountTypes/BuyXGetY.php b/packages/core/src/DiscountTypes/BuyXGetY.php index 8f53db4c38..840b32203c 100644 --- a/packages/core/src/DiscountTypes/BuyXGetY.php +++ b/packages/core/src/DiscountTypes/BuyXGetY.php @@ -249,18 +249,44 @@ private function processAutomaticRewards(CartContract $cart, int $remainingRewar // we have lines to add if ($remainingRewardQty > 0) { - while ($remainingRewardQty > 0) { - $selectedRewardItem = $this->discount->discountableRewards->random()->discountable; + // Fulfillable products per collection reward, hydrated once here rather + // than re-queried on every iteration of the allocation loop below. + $fulfillableCollectionProducts = []; - if (! $selectedRewardItem) { - $remainingRewardQty--; + $fulfillableRewards = $this->discount->discountableRewards->filter(function ($discountableReward) use (&$fulfillableCollectionProducts) { + $rewardItem = $discountableReward->discountable; - continue; + if (! $rewardItem) { + return false; + } + + if ($rewardItem instanceof LunarCollection) { + $fulfillableCollectionProducts[$rewardItem->id] = $rewardItem->products() + ->with('variants') + ->get() + ->filter(fn ($p) => $p->variants->first()?->canBeFulfilledAtQuantity(1)) + ->values(); + + return $fulfillableCollectionProducts[$rewardItem->id]->isNotEmpty(); + } + + if ($rewardItem instanceof Purchasable) { + return $rewardItem->canBeFulfilledAtQuantity(1); } + return (bool) $rewardItem->variants->first()?->canBeFulfilledAtQuantity(1); + }); + + if ($fulfillableRewards->isEmpty()) { + return [$affectedLines, $discountTotal]; + } + + while ($remainingRewardQty > 0) { + $selectedRewardItem = $fulfillableRewards->random()->discountable; + if ($selectedRewardItem instanceof LunarCollection) { - $product = $selectedRewardItem->products()->inRandomOrder()->first(); - $purchasable = $product?->variants()->first(); + $product = $fulfillableCollectionProducts[$selectedRewardItem->id]->random(); + $purchasable = $product->variants->first(); $selectedRewardItem = $product; } elseif ($selectedRewardItem instanceof Purchasable) { $purchasable = $selectedRewardItem; @@ -276,6 +302,17 @@ private function processAutomaticRewards(CartContract $cart, int $remainingRewar $rewardKey = $purchasable->getMorphClass().':'.$purchasable->id; + // How many units of this reward this run has already allocated, + // since canBeFulfilledAtQuantity below must check against that + // running total rather than a fixed quantity of 1 each time. + $allocated = $addedRewardLines[$rewardKey]->quantity ?? 0; + + if (! $purchasable->canBeFulfilledAtQuantity($allocated + 1)) { + $remainingRewardQty--; + + continue; + } + // is it already in cart? $rewardLine = $addedRewardLines[$rewardKey] ?? $cart->lines->first(function ($line) use ($purchasable) { return $line->purchasable->id == $purchasable->id; diff --git a/tests/core/Unit/DiscountTypes/BuyXGetYTest.php b/tests/core/Unit/DiscountTypes/BuyXGetYTest.php index a8cc12dd6d..96e2245ef2 100644 --- a/tests/core/Unit/DiscountTypes/BuyXGetYTest.php +++ b/tests/core/Unit/DiscountTypes/BuyXGetYTest.php @@ -1736,6 +1736,311 @@ expect($rewardLine->purchasable_type)->toEqual($purchasableB->getMorphClass()); }); +test('does not automatically add reward when product variant has no stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'purchasable' => 'in_stock', + 'stock' => 0, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Reward Out Of Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + expect($cart->freeItems)->toBeNull(); +}); + +test('does not automatically add collection reward when all products are out of stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $collection = Collection::factory()->create(); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $productB->collections()->sync($collection); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'purchasable' => 'in_stock', + 'stock' => 0, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Collection Reward Out Of Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $collection->getMorphClass(), + 'discountable_id' => $collection->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + expect($cart->freeItems)->toBeNull(); +}); + +test('automatically adds collection reward selecting in-stock product when others are out of stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $collection = Collection::factory()->create(); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); // out of stock + $productC = Product::factory()->create(); // in stock + + $productB->collections()->sync($collection); + $productC->collections()->sync($collection); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'purchasable' => 'in_stock', + 'stock' => 0, + ]); + $purchasableC = ProductVariant::factory()->create([ + 'product_id' => $productC->id, + 'purchasable' => 'in_stock', + 'stock' => 5, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB, $purchasableC] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Collection Reward Mixed Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $collection->getMorphClass(), + 'discountable_id' => $collection->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + // Only the in-stock product (C) should be added as a free item + expect($cart->freeItems)->toHaveCount(1); + expect($cart->freeItems->first()->id)->toEqual($productC->id); +}); + +test('automatically adds in-stock product reward when another reward product is out of stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $productA = Product::factory()->create(); // condition + $productB = Product::factory()->create(); // reward — out of stock + $productC = Product::factory()->create(); // reward — in stock + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'purchasable' => 'in_stock', + 'stock' => 0, + ]); + $purchasableC = ProductVariant::factory()->create([ + 'product_id' => $productC->id, + 'purchasable' => 'in_stock', + 'stock' => 5, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB, $purchasableC] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Reward Mixed Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 1, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + // Both B (no stock) and C (in stock) are rewards + $discount->discountableRewards()->createMany([ + ['discountable_type' => $productB->getMorphClass(), 'discountable_id' => $productB->id, 'type' => 'reward'], + ['discountable_type' => $productC->getMorphClass(), 'discountable_id' => $productC->id, 'type' => 'reward'], + ]); + + $cart = $cart->calculate(); + + // Product C (in stock) must be added; product B (out of stock) must never be added + expect($cart->freeItems)->toHaveCount(1); + expect($cart->freeItems->first()->id)->toEqual($productC->id); +}); + test('can add a multi quantity reward as a single line', function () { $customerGroup = CustomerGroup::factory()->create([ 'default' => true, @@ -1826,6 +2131,82 @@ expect($rewardLines->first()->quantity)->toEqual(3); }); +test('does not allocate a multi quantity reward beyond stock', function () { + $customerGroup = CustomerGroup::factory()->create(['default' => true]); + $channel = Channel::factory()->create(['default' => true]); + $currency = Currency::factory()->create(['code' => 'GBP']); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create(['product_id' => $productA->id]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + 'purchasable' => 'in_stock', + 'stock' => 1, + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->lines()->create([ + 'purchasable_type' => $purchasableA->getMorphClass(), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Automatic Reward Limited Stock', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 3, + 'automatically_add_rewards' => true, + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->discountableConditions()->create([ + 'discountable_type' => $productA->getMorphClass(), + 'discountable_id' => $productA->id, + ]); + + $discount->discountableRewards()->create([ + 'discountable_type' => $productB->getMorphClass(), + 'discountable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart->calculate(); + + $rewardLines = CartLine::where('cart_id', $cart->id) + ->where('purchasable_id', $purchasableB->id) + ->get(); + + // Only one unit is in stock, so only one is allocated despite reward_qty 3. + expect($rewardLines)->toHaveCount(1); + expect($rewardLines->first()->quantity)->toEqual(1); +}); + test('can leave a reward line the shopper added at their own quantity', function () { $customerGroup = CustomerGroup::factory()->create([ 'default' => true,