diff --git a/packages/core/src/Actions/Carts/CreateOrder.php b/packages/core/src/Actions/Carts/CreateOrder.php index 817881f303..d73ede1b7b 100644 --- a/packages/core/src/Actions/Carts/CreateOrder.php +++ b/packages/core/src/Actions/Carts/CreateOrder.php @@ -28,6 +28,11 @@ public function execute( /** @var Cart $cart */ $order = $cart->draftOrder($orderIdToUpdate)->first() ?: App::make(OrderContract::class); + // Read before the creation pipeline runs: MapDiscountBreakdown + // rewrites the order's breakdown, so afterwards every discount would + // look as though it had already been consumed. + $alreadyConsumed = $cart->consumedDiscountIds(); + if ($cart->hasCompletedOrders() && ! $allowMultipleOrders) { throw new DisallowMultipleCartOrdersException; } @@ -45,7 +50,13 @@ public function execute( return $order; }); - $cart->discounts?->each(function ($discount) use ($cart) { + // Creating the order again for the same cart - a declined card and a + // retry - must not consume a second use of the same discount. + $cart->discounts?->each(function ($discount) use ($cart, $alreadyConsumed) { + if ($alreadyConsumed->contains($discount->discount->id)) { + return; + } + $discount->markAsUsed($cart)->discount->save(); }); diff --git a/packages/core/src/DiscountTypes/AbstractDiscountType.php b/packages/core/src/DiscountTypes/AbstractDiscountType.php index 87735a83c1..3fd4cbdec9 100644 --- a/packages/core/src/DiscountTypes/AbstractDiscountType.php +++ b/packages/core/src/DiscountTypes/AbstractDiscountType.php @@ -82,9 +82,17 @@ protected function checkDiscountConditions(CartContract $cart): bool $lines = $this->getEligibleLines($cart); $validMinSpend = $minSpend ? $minSpend < $lines->sum('subTotal.value') : true; - $validMaxUses = $this->discount->max_uses ? $this->discount->uses < $this->discount->max_uses : true; + // A cart that already consumed this discount when its draft order was + // created must not then be blocked by its own use. Otherwise creating + // that order again - a declined card and a retry - re-prices it without + // the discount the shopper was quoted. + $alreadyConsumed = $cart->consumedDiscountIds()->contains($this->discount->id); - if ($validMaxUses && $this->discount->max_uses_per_user) { + $validMaxUses = $this->discount->max_uses + ? ($alreadyConsumed || $this->discount->uses < $this->discount->max_uses) + : true; + + if (! $alreadyConsumed && $validMaxUses && $this->discount->max_uses_per_user) { $validMaxUses = $cart->user && ($this->usesByUser($cart->user) < $this->discount->max_uses_per_user); } diff --git a/packages/core/src/Managers/DiscountManager.php b/packages/core/src/Managers/DiscountManager.php index b6c10d6650..2dd4b7d281 100644 --- a/packages/core/src/Managers/DiscountManager.php +++ b/packages/core/src/Managers/DiscountManager.php @@ -9,6 +9,7 @@ use Lunar\Base\Validation\CouponValidator; use Lunar\DiscountTypes\AmountOff; use Lunar\DiscountTypes\BuyXGetY; +use Lunar\Models\Cart; use Lunar\Models\Channel; use Lunar\Models\Contracts\Cart as CartContract; use Lunar\Models\Contracts\Channel as ChannelContract; @@ -137,8 +138,9 @@ public function getDiscounts(?CartContract $cart = null): Collection $this->customerGroup($defaultGroup); } + /** @var Cart $cart */ return Discount::active() - ->usable() + ->usable($cart?->consumedDiscountIds() ?? []) ->channel($this->channels) ->customerGroup($this->customerGroups) ->with([ diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php index f5197d1200..3b0386f141 100644 --- a/packages/core/src/Models/Cart.php +++ b/packages/core/src/Models/Cart.php @@ -284,6 +284,27 @@ public function draftOrder(?int $draftOrderId = null): HasOne })->whereNull('placed_at'); } + /** + * The ids of any discounts this cart has already consumed. + * + * Order creation records a use as soon as the draft order exists, so a + * checkout that runs it a second time - a declined card and a retry - would + * otherwise find its own coupon exhausted and re-price that same order + * without it. A cart's own consumption must not count against it. + */ + public function consumedDiscountIds(): Collection + { + // Read the raw column: the cast hydrates an OrderLine per breakdown + // line, which is a lot of work to reach an id. + $breakdown = $this->draftOrder()->first()?->getRawOriginal('discount_breakdown'); + + return collect(json_decode($breakdown ?: '[]', true) ?: []) + ->pluck('discount_id') + ->filter() + ->unique() + ->values(); + } + public function currentDraftOrder(?int $draftOrderId = null) { return $this->calculate() diff --git a/packages/core/src/Models/Discount.php b/packages/core/src/Models/Discount.php index 721cefe8a2..aa39ac1440 100644 --- a/packages/core/src/Models/Discount.php +++ b/packages/core/src/Models/Discount.php @@ -268,11 +268,22 @@ public function scopeProductVariants(Builder $query, iterable $variantIds = [], ); } - public function scopeUsable(Builder $query): Builder + /** + * @param iterable $exempt Discount ids that stay usable whatever their + * use count - a cart that already consumed a + * discount must still be able to re-price with it. + */ + public function scopeUsable(Builder $query, iterable $exempt = []): Builder { - return $query->where(function ($subQuery) { + $exempt = collect($exempt)->filter()->values(); + + return $query->where(function ($subQuery) use ($exempt) { $subQuery->whereRaw('uses < max_uses') - ->orWhereNull('max_uses'); + ->orWhereNull('max_uses') + ->when( + $exempt->isNotEmpty(), + fn ($subQuery) => $subQuery->orWhereIn('id', $exempt) + ); }); } } diff --git a/tests/core/Unit/Actions/Carts/CreateOrderTest.php b/tests/core/Unit/Actions/Carts/CreateOrderTest.php index fe4736b2e5..bda747f239 100644 --- a/tests/core/Unit/Actions/Carts/CreateOrderTest.php +++ b/tests/core/Unit/Actions/Carts/CreateOrderTest.php @@ -4,15 +4,19 @@ use Lunar\Actions\Carts\CreateOrder; use Lunar\DataTypes\Price as PriceDataType; use Lunar\DataTypes\ShippingOption; +use Lunar\DiscountTypes\AmountOff; use Lunar\Exceptions\DisallowMultipleCartOrdersException; +use Lunar\Facades\Discounts; use Lunar\Facades\ModelManifest; use Lunar\Facades\ShippingManifest; use Lunar\Models\Cart; use Lunar\Models\CartAddress; +use Lunar\Models\Channel; use Lunar\Models\Country; use Lunar\Models\Currency; use Lunar\Models\Customer; use Lunar\Models\CustomerGroup; +use Lunar\Models\Discount; use Lunar\Models\Order; use Lunar\Models\OrderAddress; use Lunar\Models\OrderLine; @@ -359,3 +363,258 @@ function can_update_draft_order() $this->assertDatabaseHas((new Order)->getTable(), $datacheck); }); + +test('can keep the discount when the draft order is created again', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $purchasable = ProductVariant::factory()->create(); + + 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' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + // A single-use coupon, which is the ordinary shape of a promotional code. + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $cart->calculate(); + + $orderA = (new CreateOrder)->execute($cart)->then(fn ($order) => $order->refresh()); + + expect($orderA->discount_total->value)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); + + // The card is declined and the shopper tries another one. That is a fresh + // request, so nothing is memoised from the first attempt. + Discounts::resetDiscounts(); + + $cart = Cart::find($cart->id); + $cart->calculate(); + + $orderB = (new CreateOrder)->execute($cart)->then(fn ($order) => $order->refresh()); + + expect($orderB->id)->toEqual($orderA->id); + expect($orderB->discount_total->value)->toEqual(500); + + // The retry must not consume a second use of a single-use coupon. + expect($discount->refresh()->uses)->toEqual(1); +}); + +test('can not reuse a discount another cart has exhausted', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $makeCart = function () use ($currency, $channel, $purchasable) { + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + return $cart; + }; + + $cartA = $makeCart(); + $cartA->calculate(); + $orderA = (new CreateOrder)->execute($cartA)->then(fn ($order) => $order->refresh()); + + expect($orderA->discount_total->value)->toEqual(500); + expect($discount->refresh()->uses)->toEqual(1); + + // A different shopper, with the last use already spent. + Discounts::resetDiscounts(); + + $cartB = $makeCart(); + $cartB->calculate(); + $orderB = (new CreateOrder)->execute($cartB)->then(fn ($order) => $order->refresh()); + + expect($orderB->id)->not->toEqual($orderA->id); + expect($orderB->discount_total->value)->toEqual(0); + expect($discount->refresh()->uses)->toEqual(1); +}); + +test('can still enforce other conditions on a discount the cart consumed', function () { + TaxClass::factory()->create([ + 'default' => true, + ]); + + $customerGroup = CustomerGroup::factory()->create([ + 'default' => true, + ]); + + $channel = Channel::factory()->create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'decimal_places' => 2, + ]); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + 'coupon_code' => 'SAVE10', + ]); + + $purchasable = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $line = $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 2, + ]); + + // Spend at least 15.00 to qualify. Two units is 20.00, one is 10.00. + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Ten off', + 'coupon' => 'SAVE10', + 'uses' => 0, + 'max_uses' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 500, + ], + 'min_prices' => [ + $currency->code => 1500, + ], + ], + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $discount->channels()->sync([ + $channel->id => ['enabled' => true, 'starts_at' => now()->subHour()], + ]); + + $cart->calculate(); + + $orderA = (new CreateOrder)->execute($cart)->then(fn ($order) => $order->refresh()); + + expect($orderA->discount_total->value)->toEqual(500); + + // The shopper drops a unit, taking the cart under the minimum spend. Being + // the cart that consumed the discount must not exempt it from that. + Discounts::resetDiscounts(); + + $cart = Cart::find($cart->id); + $cart->updateLine($line->id, 1); + $cart->calculate(); + + $orderB = (new CreateOrder)->execute($cart)->then(fn ($order) => $order->refresh()); + + expect($orderB->id)->toEqual($orderA->id); + expect($orderB->sub_total->value)->toEqual(1000); + expect($orderB->discount_total->value)->toEqual(0); +});