Skip to content
Merged
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
13 changes: 12 additions & 1 deletion packages/core/src/Actions/Carts/CreateOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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();
});

Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/DiscountTypes/AbstractDiscountType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/Managers/DiscountManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/Models/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/Models/Discount.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
});
}
}
259 changes: 259 additions & 0 deletions tests/core/Unit/Actions/Carts/CreateOrderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});