Skip to content

Commit

Permalink
Add max user uses to discounts (#892)
Browse files Browse the repository at this point in the history
* Add ability to limit discount uses by users

* Pint

* Wrong relation

* Use $cart->user instead of auth

* Fix test bug

* Fix test bugs

* Change field type

* Bug fixes
  • Loading branch information
ryanmitchell authored Mar 24, 2023
1 parent aff3916 commit 6f28e8d
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 3 deletions.
1 change: 1 addition & 0 deletions packages/admin/resources/lang/en/inputs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
/>
</x-hub::input.group>
</div>

<div>
<x-hub::input.group for="max_uses_per_user" :error="$errors->first('discount.max_uses_per_user')" :label="__('adminhub::inputs.max_uses_per_user.label')" instructions="Leave blank for unlimited uses.">
<x-hub::input.text type="number" wire:model="discount.max_uses_per_user" />
</x-hub::input.group>
</div>
</div>


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -431,6 +432,7 @@ public function getSideMenuProperty()
'has_errors' => $this->errorBag->hasAny([
'minPrices.*.price',
'discount.max_uses',
'discount.max_uses_per_user',
]),
],
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;

class AddMaxUsesPerUserToDiscountsTable extends Migration
{
public function up()
{
Schema::table($this->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');
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;

class CreateDiscountUserTable extends Migration
{
public function up()
{
Schema::create($this->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');
}
}
4 changes: 2 additions & 2 deletions packages/core/src/Actions/Carts/CreateOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/DiscountTypes/AbstractDiscountType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
}
}
10 changes: 10 additions & 0 deletions packages/core/src/Models/Discount.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
164 changes: 164 additions & 0 deletions packages/core/tests/Unit/DiscountTypes/AmountOffTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
}
}

0 comments on commit 6f28e8d

Please sign in to comment.