diff --git a/packages/admin/resources/lang/en/inputs.php b/packages/admin/resources/lang/en/inputs.php index 658a9422a7..62cca9219b 100644 --- a/packages/admin/resources/lang/en/inputs.php +++ b/packages/admin/resources/lang/en/inputs.php @@ -105,5 +105,6 @@ 'postcodes.label' => 'Postcodes', 'postcodes.instructions' => 'List each postcode on a new line. Supports wildcards such as NW*', 'max_uses.label' => 'Max uses', + 'max_uses_per_user.label' => 'Max uses per user', 'size.placeholder' => 'Size', ]; diff --git a/packages/admin/resources/views/partials/forms/discount/conditions.blade.php b/packages/admin/resources/views/partials/forms/discount/conditions.blade.php index e36d94c347..98b4ad8a51 100644 --- a/packages/admin/resources/views/partials/forms/discount/conditions.blade.php +++ b/packages/admin/resources/views/partials/forms/discount/conditions.blade.php @@ -47,6 +47,12 @@ /> + +
+ + + +
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php b/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php index 8308ad7d67..3cf30fcb35 100644 --- a/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php +++ b/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php @@ -341,6 +341,7 @@ public function save() DB::transaction(function () { $this->discount->max_uses = $this->discount->max_uses ?: null; + $this->discount->max_uses_per_user = $this->discount->max_uses_per_user ?: null; $this->discount->save(); $this->discount->brands()->sync( @@ -431,6 +432,7 @@ public function getSideMenuProperty() 'has_errors' => $this->errorBag->hasAny([ 'minPrices.*.price', 'discount.max_uses', + 'discount.max_uses_per_user', ]), ], [ diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php index 6931dc5d1c..88578e1598 100644 --- a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php +++ b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php @@ -46,6 +46,7 @@ public function rules() 'discount.handle' => 'required|unique:'.Discount::class.',handle', 'discount.stop' => 'nullable', 'discount.max_uses' => 'nullable|numeric|min:0', + 'discount.max_uses_per_user' => 'nullable|numeric|min:0', 'discount.priority' => 'required|min:1', 'discount.starts_at' => 'date', 'discount.coupon' => 'nullable', diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php index 1020216d2a..817bdc7055 100644 --- a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php +++ b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php @@ -32,6 +32,7 @@ public function rules() 'discount.handle' => 'required|unique:'.Discount::class.',handle,'.$this->discount->id, 'discount.stop' => 'nullable', 'discount.max_uses' => 'nullable|numeric|min:0', + 'discount.max_uses_per_user' => 'nullable|numeric|min:0', 'discount.priority' => 'required|min:1', 'discount.starts_at' => 'date', 'discount.coupon' => 'nullable', @@ -73,6 +74,7 @@ public function delete() $this->discount->collections()->delete(); $this->discount->customerGroups()->detach(); $this->discount->channels()->detach(); + $this->discount->users()->delete(); $this->discount->delete(); }); diff --git a/packages/core/database/migrations/2023_03_03_100001_add_max_uses_per_user_to_discounts_table.php b/packages/core/database/migrations/2023_03_03_100001_add_max_uses_per_user_to_discounts_table.php new file mode 100644 index 0000000000..d5401fcf6f --- /dev/null +++ b/packages/core/database/migrations/2023_03_03_100001_add_max_uses_per_user_to_discounts_table.php @@ -0,0 +1,22 @@ +prefix.'discounts', function (Blueprint $table) { + $table->mediumInteger('max_uses_per_user')->unsigned()->nullable()->after('max_uses'); + }); + } + + public function down() + { + Schema::table($this->prefix.'discounts', function ($table) { + $table->dropColumn('max_uses_per_user'); + }); + } +} diff --git a/packages/core/database/migrations/2023_03_13_100030_create_discount_user_table.php b/packages/core/database/migrations/2023_03_13_100030_create_discount_user_table.php new file mode 100644 index 0000000000..1a1b94a765 --- /dev/null +++ b/packages/core/database/migrations/2023_03_13_100030_create_discount_user_table.php @@ -0,0 +1,23 @@ +prefix.'discount_user', function (Blueprint $table) { + $table->id(); + $table->foreignId('discount_id')->constrained($this->prefix.'discounts')->cascadeOnDelete(); + $table->userForeignKey(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists($this->prefix.'discount_user'); + } +} diff --git a/packages/core/src/Actions/Carts/CreateOrder.php b/packages/core/src/Actions/Carts/CreateOrder.php index 640599347c..755944b97f 100644 --- a/packages/core/src/Actions/Carts/CreateOrder.php +++ b/packages/core/src/Actions/Carts/CreateOrder.php @@ -152,8 +152,8 @@ public function execute( $cart->order()->associate($order); - $cart->discounts?->each(function ($discount) { - $discount->markAsUsed()->discount->save(); + $cart->discounts?->each(function ($discount) use ($cart) { + $discount->markAsUsed($cart)->discount->save(); }); $cart->save(); diff --git a/packages/core/src/DiscountTypes/AbstractDiscountType.php b/packages/core/src/DiscountTypes/AbstractDiscountType.php index e429ea54b4..e83999e10c 100644 --- a/packages/core/src/DiscountTypes/AbstractDiscountType.php +++ b/packages/core/src/DiscountTypes/AbstractDiscountType.php @@ -2,7 +2,9 @@ namespace Lunar\DiscountTypes; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use Lunar\Base\DiscountTypeInterface; use Lunar\Base\ValueObjects\Cart\DiscountBreakdown; use Lunar\Models\Cart; @@ -35,10 +37,14 @@ public function with(Discount $discount): self * * @return self */ - public function markAsUsed(): self + public function markAsUsed(Cart $cart): self { $this->discount->uses = $this->discount->uses + 1; + if ($user = $cart->user) { + $this->discount->users()->attach($user); + } + return $this; } @@ -76,6 +82,10 @@ protected function checkDiscountConditions(Cart $cart): bool $validMaxUses = $this->discount->max_uses ? $this->discount->uses < $this->discount->max_uses : true; + if ($validMaxUses && $this->discount->max_uses_per_user) { + $validMaxUses = $cart->user && ($this->usesByUser($cart->user) < $this->discount->max_uses_per_user); + } + return $validCoupon && $validMinSpend && $validMaxUses; } @@ -96,4 +106,17 @@ protected function addDiscountBreakdown(Cart $cart, DiscountBreakdown $breakdown return $this; } + + /** + * Check how many times this discount has been used by the logged in user's customers + * + * @param Illuminate\Contracts\Auth\Authenticatable $user + * @return int + */ + protected function usesByUser(Authenticatable $user) + { + return $this->discount->users() + ->whereUserId($user->getKey()) + ->count(); + } } diff --git a/packages/core/src/Models/Discount.php b/packages/core/src/Models/Discount.php index 29c0b1a31c..1256950acf 100644 --- a/packages/core/src/Models/Discount.php +++ b/packages/core/src/Models/Discount.php @@ -57,6 +57,16 @@ protected static function newFactory(): DiscountFactory return DiscountFactory::new(); } + public function users() + { + $prefix = config('lunar.database.table_prefix'); + + return $this->belongsToMany( + config('auth.providers.users.model'), + "{$prefix}discount_user" + )->withTimestamps(); + } + /** * Return the purchasables relationship. * diff --git a/packages/core/tests/Unit/DiscountTypes/AmountOffTest.php b/packages/core/tests/Unit/DiscountTypes/AmountOffTest.php index 1be9e37f09..479ccb2e50 100644 --- a/packages/core/tests/Unit/DiscountTypes/AmountOffTest.php +++ b/packages/core/tests/Unit/DiscountTypes/AmountOffTest.php @@ -8,11 +8,13 @@ use Lunar\Models\Cart; use Lunar\Models\Channel; use Lunar\Models\Currency; +use Lunar\Models\Customer; use Lunar\Models\CustomerGroup; use Lunar\Models\Discount; use Lunar\Models\Price; use Lunar\Models\Product; use Lunar\Models\ProductVariant; +use Lunar\Tests\Stubs\User; use Lunar\Tests\TestCase; /** @@ -971,4 +973,166 @@ public function can_apply_discount_with_conditions() $this->assertEquals(1800, $cart->taxTotal->value); $this->assertCount(1, $cart->discounts); } + + /** + * @test + */ + public function can_apply_discount_with_max_user_uses() + { + $currency = Currency::getDefault(); + + $customerGroup = CustomerGroup::getDefault(); + + $channel = Channel::getDefault(); + + $user = User::factory()->create(); + $customer = Customer::factory()->create(); + $customer->customerGroups()->attach($customerGroup); + + $user->customers()->attach($customer); + + $this->actingAs($user); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + ]); + + $cart->user()->associate($user); + + $purchasableA = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'tier' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => get_class($purchasableA), + 'priceable_id' => $purchasableA->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => get_class($purchasableA), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 2, + ]); + + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Test Coupon', + 'uses' => 0, + 'max_uses' => 10, + 'max_uses_per_user' => 2, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + 'GBP' => 10, + ], + ], + ]); + + $discount->users()->sync([ + $user->id, + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => [ + 'enabled' => true, + 'starts_at' => now(), + ], + ]); + + $discount->channels()->sync([ + $channel->id => [ + 'enabled' => true, + 'starts_at' => now()->subHour(), + ], + ]); + + $cart = $cart->calculate(); + + $this->assertEquals(1000, $cart->discountTotal->value); + $this->assertEquals(1200, $cart->total->value); + $this->assertEquals(1000, $cart->subTotal->value); + } + + /** + * @test + */ + public function cannot_apply_discount_with_max_user_uses() + { + $currency = Currency::getDefault(); + + $customerGroup = CustomerGroup::getDefault(); + + $channel = Channel::getDefault(); + + $user = User::factory()->create(); + $customer = Customer::factory()->create(); + $customer->customerGroups()->attach($customerGroup); + + $user->customers()->attach($customer); + + $this->actingAs($user); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + 'channel_id' => $channel->id, + ]); + + $cart->user()->associate($user); + + $purchasableA = ProductVariant::factory()->create(); + + Price::factory()->create([ + 'price' => 1000, // £10 + 'tier' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => get_class($purchasableA), + 'priceable_id' => $purchasableA->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => get_class($purchasableA), + 'purchasable_id' => $purchasableA->id, + 'quantity' => 2, + ]); + + $discount = Discount::factory()->create([ + 'type' => AmountOff::class, + 'name' => 'Test Coupon', + 'uses' => 0, + 'max_uses' => 10, + 'max_uses_per_user' => 1, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + 'GBP' => 10, + ], + ], + ]); + + $discount->users()->sync([ + $user->id, + ]); + + $discount->customerGroups()->sync([ + $customerGroup->id => [ + 'enabled' => true, + 'starts_at' => now(), + ], + ]); + + $discount->channels()->sync([ + $channel->id => [ + 'enabled' => true, + 'starts_at' => now()->subHour(), + ], + ]); + + $cart = $cart->calculate(); + + $this->assertEquals(0, $cart->discountTotal->value); + $this->assertEquals(2400, $cart->total->value); + $this->assertEquals(2000, $cart->subTotal->value); + } }