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