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 @@
+
+ @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
*/