diff --git a/packages/admin/resources/views/livewire/components/discounts/types/buy-x-get-y.blade.php b/packages/admin/resources/views/livewire/components/discounts/types/buy-x-get-y.blade.php index 5b7a95a719..e3aa51e013 100644 --- a/packages/admin/resources/views/livewire/components/discounts/types/buy-x-get-y.blade.php +++ b/packages/admin/resources/views/livewire/components/discounts/types/buy-x-get-y.blade.php @@ -1,6 +1,5 @@
-
Qualify Products @@ -100,6 +99,14 @@ class="rounded border px-3 py-2 flex items-center"
@endforeach
+ + @if($this->purchasableRewards->count()) +
+ + + +
+ @endif If one or more items are in the cart, the cheapest item will be discounted. diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php b/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php index ace1326796..9b03734038 100644 --- a/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php +++ b/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php @@ -35,6 +35,7 @@ public function rules() 'discount.data.min_qty' => 'required', 'discount.data.reward_qty' => 'required|numeric', 'discount.data.max_reward_qty' => 'required|numeric', + 'discount.data.automatically_add_rewards' => 'nullable|boolean', 'selectedConditions' => 'array|min:1', 'selectedRewards' => 'array|min:1', ]; diff --git a/packages/core/src/DiscountTypes/BuyXGetY.php b/packages/core/src/DiscountTypes/BuyXGetY.php index be78ba7bcf..3020e3de72 100644 --- a/packages/core/src/DiscountTypes/BuyXGetY.php +++ b/packages/core/src/DiscountTypes/BuyXGetY.php @@ -2,6 +2,8 @@ namespace Lunar\DiscountTypes; +use Illuminate\Pipeline\Pipeline; +use Illuminate\Support\Collection; use Lunar\Base\ValueObjects\Cart\DiscountBreakdown; use Lunar\Base\ValueObjects\Cart\DiscountBreakdownLine; use Lunar\DataTypes\Price; @@ -51,6 +53,7 @@ public function apply(Cart $cart): Cart $minQty = $data['min_qty'] ?? null; $rewardQty = $data['reward_qty'] ?? 1; $maxRewardQty = $data['max_reward_qty'] ?? null; + $automaticallyAddRewards = $data['automatically_add_rewards'] ?? false; // Get all purchasables that are eligible. $conditions = $cart->lines->reject(function ($line) { @@ -163,6 +166,10 @@ public function apply(Cart $cart): Cart $cart->freeItems->push($rewardLine->purchasable); } + if ($automaticallyAddRewards) { + [$affectedLines, $discountTotal] = $this->processAutomaticRewards($cart, $remainingRewardQty, $affectedLines, $discountTotal); + } + $this->addDiscountBreakdown($cart, new DiscountBreakdown( discount: $this->discount, lines: $affectedLines, @@ -171,4 +178,151 @@ public function apply(Cart $cart): Cart return $cart; } + + private function processAutomaticRewards(Cart $cart, int $remainingRewardQty, Collection $affectedLines, int $discountTotal) + { + $automaticLines = $cart->lines->filter(function ($line) { + return in_array($this->discount->id, array_keys($line->meta->added_by_discount ?? [])); + }); + + $remainingRewardQty -= $automaticLines->sum(function ($line) { + return $line->meta->added_by_discount[$this->discount->id] ?? 0; + }); + + // we have lines to add + if ($remainingRewardQty > 0) { + while ($remainingRewardQty > 0) { + $selectedRewardItem = $this->discount->purchasableRewards->random()->purchasable; + $purchasable = $selectedRewardItem->variants->first(); + + // is it already in cart? + $rewardLine = $cart->lines->first(function ($line) use ($purchasable) { + return $line->purchasable->id == $purchasable->id; + }); + + if (! $rewardLine) { + $rewardLine = $cart->lines()->make([ + 'purchasable_type' => get_class($purchasable), + 'purchasable_id' => $purchasable->id, + 'quantity' => 1, + ]); + + if (! $cart->freeItems) { + $cart->freeItems = collect(); + } + + if (! $cart->freeItems->contains($selectedRewardItem)) { + $cart->freeItems->push($selectedRewardItem); + } + + $rewardLine = app(Pipeline::class) + ->send($rewardLine) + ->through( + config('lunar.cart.pipelines.cart_lines', []) + )->thenReturn(function ($cartLine) { + $cartLine->cacheProperties(); + + return $cartLine; + }); + + $unitQuantity = $purchasable->getUnitQuantity(); + + $rewardLine->subTotal = new Price($rewardLine->unitPrice->value, $cart->currency, $unitQuantity); + $rewardLine->taxAmount = new Price(0, $cart->currency, $unitQuantity); + $rewardLine->total = new Price($rewardLine->unitPrice->value, $cart->currency, $unitQuantity); + } + + $meta = $rewardLine->meta ?? json_decode('{}'); + if (! isset($meta->added_by_discount)) { + $meta->added_by_discount = []; + } + + if (! isset($meta->added_by_discount[$this->discount->id])) { + $meta->added_by_discount[$this->discount->id] = 1; + } else { + $meta->added_by_discount[$this->discount->id]++; + } + + $affectedLine = $affectedLines->first(function ($line) use ($rewardLine) { + return $line->line == $rewardLine; + }); + + if (! $affectedLine) { + $affectedLines->push(new DiscountBreakdownLine( + line: $rewardLine, + quantity: 1 + )); + } else { + $affectedLine->quantity++; + } + + $unitPrice = $rewardLine->unitPrice->value; + + $discountTotal += $unitPrice; + + $rewardLine->discountTotal = new Price( + ($rewardLine->discountTotal?->value ?? 0) + $unitPrice, + $cart->currency, + 1 + ); + + $rewardLine->subTotalDiscounted = new Price( + $rewardLine->subTotal->value - $rewardLine->discountTotal->value, + $cart->currency, + 1 + ); + + $rewardLine->meta = $meta; + $rewardLine->save(); + + $remainingRewardQty--; + } + + // we have lines to remove + } elseif ($remainingRewardQty < 0) { + // while handles the situation where quantity of an item may be more than 1 + while ($remainingRewardQty > 0 && ! empty($automaticLines)) { + // loop over automatic lines and decrement quantity + foreach ($automaticLines as $index => $line) { + if ($remainingRewardQty >= 0) { + continue; + } + + $meta = $line->meta; + $addedByDiscountQty = $meta->added_by_discount[$this->discount->id] ?? 0; + + if ($addedByDiscountQty > 0) { + $line->quantity = $line->quantity - 1; + $addedByDiscountQty--; + $remainingRewardQty++; + + if ($addedByDiscountQty < 1) { + unset($meta->added_by_discount[$this->discount->id]); + } else { + $meta->added_by_discount[$this->discount->id] = $addedByDiscountQty; + } + + if (empty($meta->added_by_discount)) { + unset($meta->added_by_discount); + } + + $line->meta = $meta; + } + + if ($line->quantity > 0) { + $line->save(); + } else { + $line->delete(); + $cart->freeItems->remove($line->product); + } + + if ($addedByDiscountQty <= 0) { + unset($automaticLines[$index]); + } + } + } + } + + return [$affectedLines, $discountTotal]; + } } diff --git a/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php b/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php index d632c03e01..7c7972ccbd 100644 --- a/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php +++ b/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php @@ -337,6 +337,102 @@ public function can_discount_eligible_products() $this->assertCount(1, $cart->freeItems); } + /** + * @test + */ + public function can_add_eligible_products_when_not_cart() + { + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'code' => 'GBP', + ]); + + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + ]); + + $productA = Product::factory()->create(); + $productB = Product::factory()->create(); + + $purchasableA = ProductVariant::factory()->create([ + 'product_id' => $productA->id, + ]); + $purchasableB = ProductVariant::factory()->create([ + 'product_id' => $productB->id, + ]); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'tier' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => get_class($purchasableA), + 'priceable_id' => $purchasableA->id, + ]); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'tier' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => get_class($purchasableB), + 'priceable_id' => $purchasableB->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => get_class($purchasableA), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'name' => 'Test Product Discount', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 2, + '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->purchasableConditions()->create([ + 'purchasable_type' => Product::class, + 'purchasable_id' => $productA->id, + ]); + + $discount->purchasableRewards()->create([ + 'purchasable_type' => Product::class, + 'purchasable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + $this->assertEquals(1200, $cart->total->value); + $this->assertCount(1, $cart->freeItems); + + } + /** * @test */