diff --git a/packages/core/src/DiscountTypes/AmountOff.php b/packages/core/src/DiscountTypes/AmountOff.php index 777bb62d88..c603fc2d72 100644 --- a/packages/core/src/DiscountTypes/AmountOff.php +++ b/packages/core/src/DiscountTypes/AmountOff.php @@ -80,6 +80,13 @@ private function applyFixedValue(array $values, Cart $cart): Cart } else { $amount = $roundedChunk; } + + // If this discount already has a greater discount value + // don't add this one as they already have a better deal. + if ($line->discountTotal->value > $amount) { + continue; + } + $remaining -= $amount; $line->discountTotal = new Price( @@ -190,12 +197,18 @@ private function applyPercentage($value, $cart): Cart foreach ($lines as $line) { $subTotal = $line->subTotal->value; + $subTotalDiscounted = $line->subTotalDiscounted?->value ?: 0; + + if ($subTotalDiscounted) { + $subTotal = $subTotalDiscounted; + } + $amount = (int) round($subTotal * ($value / 100)); $totalDiscount += $amount; $line->discountTotal = new Price( - $amount, + $subTotalDiscounted + $amount, $cart->currency, 1 ); diff --git a/packages/core/src/DiscountTypes/BuyXGetY.php b/packages/core/src/DiscountTypes/BuyXGetY.php index 893a5d4bc5..0e667c44b5 100644 --- a/packages/core/src/DiscountTypes/BuyXGetY.php +++ b/packages/core/src/DiscountTypes/BuyXGetY.php @@ -33,7 +33,7 @@ public function getName(): string */ public function getRewardQuantity($linesQuantity, $minQty, $rewardQty, $maxRewardQty = null) { - $result = ($linesQuantity / $minQty) * $rewardQty; + $result = ($linesQuantity / ($minQty ?: 1)) * $rewardQty; if ($maxRewardQty && $result > $maxRewardQty) { return $maxRewardQty; @@ -77,6 +77,7 @@ public function apply(Cart $cart): Cart $maxRewardQty ); + if (! $totalRewardQty) { return $cart; } @@ -94,6 +95,7 @@ public function apply(Cart $cart): Cart }); })->sortBy('subTotal.value'); + foreach ($rewardLines as $rewardLine) { if (! $remainingRewardQty) { continue; @@ -101,7 +103,12 @@ public function apply(Cart $cart): Cart $remainder = $rewardLine->quantity % $remainingRewardQty; - $qtyToAllocate = (int) floor(($remainingRewardQty - $remainder) / $rewardLine->quantity); + + $qtyToAllocate = (int) round(($remainingRewardQty - $remainder) / $rewardLine->quantity); + + if (!$remainder && $remainingRewardQty < $rewardLine->quantity) { + $qtyToAllocate = $remainingRewardQty; + } if (! $qtyToAllocate) { continue; @@ -112,6 +119,7 @@ public function apply(Cart $cart): Cart quantity: $qtyToAllocate )); + $conditionQtyToAllocate = $qtyToAllocate * $rewardQty; $conditions->each(function ($conditionLine) use ($affectedLines, &$conditionQtyToAllocate) { if (! $conditionQtyToAllocate) { @@ -132,8 +140,9 @@ public function apply(Cart $cart): Cart $remainingRewardQty -= $qtyToAllocate; $subTotal = $rewardLine->subTotal->value; + $unitPrice = $rewardLine->unitPrice->value; - $lineDiscountTotal = $subTotal * $qtyToAllocate; + $lineDiscountTotal = $unitPrice * $qtyToAllocate; $discountTotal += $lineDiscountTotal; $rewardLine->discountTotal = new Price( diff --git a/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php b/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php index b329accc40..5f9d9cf552 100644 --- a/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php +++ b/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php @@ -3,6 +3,7 @@ namespace Lunar\Tests\Unit\DiscountTypes; use Illuminate\Foundation\Testing\RefreshDatabase; +use Lunar\DiscountTypes\AmountOff; use Lunar\DiscountTypes\BuyXGetY; use Lunar\Models\Cart; use Lunar\Models\Channel; @@ -283,4 +284,281 @@ public function can_discount_eligible_products() $this->assertEquals(1200, $cart->total->value); $this->assertCount(1, $cart->freeItems); } + + /** + * @test + */ + public function can_discount_purchasable_with_priority() + { + $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, + ]); + + $cart->lines()->create([ + 'purchasable_type' => get_class($purchasableB), + 'purchasable_id' => $purchasableB->id, + 'quantity' => 1, + ]); + + $discount = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'priority' => 2, + 'name' => 'Test Product Discount', + 'data' => [ + 'min_qty' => 1, + 'reward_qty' => 2, + ], + ]); + + Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Test A amount off', + 'uses' => 0, + 'priority' => 1, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + 'GBP' => 10, + ], + 'min_prices' => [ + 'GBP' => 0, + ], + ], + ]); + + $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 + * @group flub + * + * Scenario + * ---------------------------------------------------- + * + * Product A costs 10.00 before tax + * Product B costs 5.00 before tax + * Discount A is a BuyXGetY. Reward: Product B Condition: 2 x Product A + * Discount B is an Amount off for 10% + * + * Cart: + * 2 x Product A = (10.00 x 2) - 10% = 18.00 excl tax + * 2 x Product B = ((5.00 x 2) - 5.00) - 10% = 4.50 excl tax + * Sub total: 22.50 + * Discount total: 7.50 + * Tax total: 4.50 + * Total: 27.00 + */ + public function can_apply_multiple_different_discounts() + { + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'code' => 'GBP', + ]); + + /** + * Product set up. + */ + $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' => 500, // £5 + 'tier' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => get_class($purchasableB), + 'priceable_id' => $purchasableB->id, + ]); + + /** + * Cart set up. + */ + $cart = Cart::factory()->create([ + 'channel_id' => $channel->id, + 'currency_id' => $currency->id, + 'coupon_code' => 'AMOUNTOFFTEST', + ]); + + $cart->lines()->create([ + 'purchasable_type' => get_class($purchasableA), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 2, + ]); + + $cart->lines()->create([ + 'purchasable_type' => get_class($purchasableB), + 'purchasable_id' => $purchasableB->id, + 'quantity' => 2, + ]); + + /** + * Discount set up. + */ + + $discountA = Discount::factory()->create([ + 'type' => BuyXGetY::class, + 'priority' => 1, + 'name' => 'Test Product Discount', + 'data' => [ + 'min_qty' => 2, + 'reward_qty' => 1, + ], + ]); + + $discountB = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Test amount off', + 'uses' => 0, + 'priority' => 1, + 'max_uses' => 1, + 'coupon' => 'AMOUNTOFFTEST', + 'data' => [ + 'fixed_value' => false, + 'percentage' => 10, + ], + ]); + + foreach ([$discountA, $discountB] as $discount) { + $discount->customerGroups()->sync([ + $customerGroup->id => [ + 'enabled' => true, + 'starts_at' => now(), + ], + ]); + $discount->channels()->sync([ + $channel->id => [ + 'enabled' => true, + 'starts_at' => now()->subHour(), + ], + ]); + } + + $discountA->purchasableConditions()->create([ + 'purchasable_type' => Product::class, + 'purchasable_id' => $productA->id, + ]); + + $discountA->purchasableRewards()->create([ + 'purchasable_type' => Product::class, + 'purchasable_id' => $productB->id, + 'type' => 'reward', + ]); + + $cart = $cart->calculate(); + + $lineA = $cart->lines->first(function ($line) use ($purchasableA) { + return $line->purchasable_id == $purchasableA->id; + }); + + $lineB = $cart->lines->first(function ($line) use ($purchasableB) { + return $line->purchasable_id == $purchasableB->id; + }); + + $this->assertEquals(2000, $lineA->subTotal->value); + $this->assertEquals(1800, $lineA->subTotalDiscounted->value); + $this->assertEquals(200, $lineA->discountTotal->value); + + $this->assertEquals(1000, $lineB->subTotal->value); + $this->assertEquals(450, $lineB->subTotalDiscounted->value); + $this->assertEquals(550, $lineB->discountTotal->value); + + $this->assertEquals(750, $cart->discountTotal->value); + $this->assertCount(2, $cart->discountBreakdown); + } }