Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions packages/core/src/DiscountTypes/BuyXGetY.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,18 +238,37 @@ private function processAutomaticRewards(CartContract $cart, int $remainingRewar
{
// we have lines to add
if ($remainingRewardQty > 0) {
while ($remainingRewardQty > 0) {
$selectedRewardItem = $this->discount->discountableRewards->random()->discountable;
$fulfillableRewards = $this->discount->discountableRewards->filter(function ($discountableReward) {
$rewardItem = $discountableReward->discountable;

if (! $selectedRewardItem) {
$remainingRewardQty--;
if (! $rewardItem) {
return false;
}

continue;
if ($rewardItem instanceof LunarCollection) {
return $rewardItem->products()
->with('variants')
->get()
->some(fn ($p) => $p->variants->first()?->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 = $selectedRewardItem->products()
->inRandomOrder()
->with('variants')
->get()
->first(fn ($p) => $p->variants->first()?->canBeFulfilledAtQuantity(1));
$purchasable = $product?->variants->first();
$selectedRewardItem = $product;
} else {
$purchasable = $selectedRewardItem->variants->first();
Expand All @@ -261,6 +280,12 @@ private function processAutomaticRewards(CartContract $cart, int $remainingRewar
continue;
}

if (! $purchasable->canBeFulfilledAtQuantity(1)) {
$remainingRewardQty--;

continue;
}

// is it already in cart?
$rewardLine = $cart->lines->first(function ($line) use ($purchasable) {
return $line->purchasable->id == $purchasable->id;
Expand Down
305 changes: 305 additions & 0 deletions tests/core/Unit/DiscountTypes/BuyXGetYTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1548,3 +1548,308 @@
// productC (not in collection) should not be discounted
expect($lineC->discountTotal->value)->toEqual(0);
});

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()->or->toBeEmpty();
});

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()->or->toBeEmpty();
});

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);
});