Skip to content

Commit

Permalink
Feature: Toggle interaction limitations on coupons and discounts
Browse files Browse the repository at this point in the history
This feature allows customization of the interaction between coupons and discounts that are applied to the cart of the user. Discounts can disallow usage of coupons and vice versa.

Also added in this feature is a fix for the validation exception response in the cart, this would show the error message related to "code" to both coupons add input field and the gift card add input field.

Closes: feature/coupon-and-discount-interaction-limitations
  • Loading branch information
illuminatisx committed Feb 26, 2024
1 parent b2c7baf commit d24dd79
Show file tree
Hide file tree
Showing 14 changed files with 195 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shop_coupons', function (Blueprint $table) {
$table->boolean('discount_allowed')->default(true)->after('can_cumulate');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shop_coupons', function (Blueprint $table) {
$table->dropColumn('discount_allowed');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shop_discounts', function (Blueprint $table) {
$table->boolean('coupons_allowed')->default(true)->after('discount');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shop_discounts', function (Blueprint $table) {
$table->dropColumn('coupons_allowed');
});
}
};
2 changes: 2 additions & 0 deletions resources/lang/en/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
'active' => 'Active',
'usage' => 'Under usage limit',
'enable' => 'Enable the coupon',
'discount' => 'Allow usage of discounts with coupon',
],

'giftcards' => [
Expand All @@ -72,6 +73,7 @@
'global' => 'Should the discount be active on all the shop ?',
'active' => 'Active',
'enable' => 'Enable the discount',
'coupons' => 'Allow usage of coupons with discount',
],

'packages' => [
Expand Down
1 change: 1 addition & 0 deletions resources/lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
'add' => 'Add a coupon',
'error' => 'This coupon does not exist, has expired or can no longer be used.',
'cumulate' => 'You cannot use this coupon with an other coupon.',
'discount' => 'You cannot use this coupon with one or more of the existing discounts applied.',
],

'payment' => [
Expand Down
5 changes: 5 additions & 0 deletions resources/views/admin/coupons/_form.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,8 @@
<input type="checkbox" class="form-check-input" id="enableSwitch" name="is_enabled" @checked($coupon->is_enabled ?? true)>
<label class="form-check-label" for="enableSwitch">{{ trans('shop::admin.coupons.enable') }}</label>
</div>

<div class="mb-3 form-check form-switch">
<input type="checkbox" class="form-check-input" id="discountSwitch" name="discount_allowed" @checked($coupon->discount_allowed ?? true)>
<label class="form-check-label" for="discountSwitch">{{ trans('shop::admin.coupons.discount') }}</label>
</div>
5 changes: 5 additions & 0 deletions resources/views/admin/discounts/_form.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,8 @@
<input type="checkbox" class="form-check-input" id="enableSwitch" name="is_enabled" @checked($discount->is_enabled ?? true)>
<label class="form-check-label" for="enableSwitch">{{ trans('shop::admin.discounts.enable') }}</label>
</div>

<div class="mb-3 form-check form-switch">
<input type="checkbox" class="form-check-input" id="allowCouponsSwitch" name="coupons_allowed" @checked($discount->coupons_allowed ?? true)>
<label class="form-check-label" for="allowCouponsSwitch">{{ trans('shop::admin.discounts.coupons') }}</label>
</div>
6 changes: 3 additions & 3 deletions resources/views/cart/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@
<form action="{{ route('shop.cart.coupons.add') }}" method="POST" >
@csrf

<div class="input-group mb-3 @error('code') has-validation @enderror">
<input type="text" class="form-control @error('code') is-invalid @enderror" placeholder="{{ trans('shop::messages.fields.code') }}" id="code" name="code" value="{{ old('code') }}">
<div class="input-group mb-3 @error('coupon_code') has-validation @enderror">
<input type="text" class="form-control @error('coupon_code') is-invalid @enderror" placeholder="{{ trans('shop::messages.fields.code') }}" id="coupon_code" name="coupon_code" value="{{ old('coupon_code') }}">

<button type="submit" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> {{ trans('messages.actions.add') }}
</button>

@error('code')
@error('coupon_code')
<span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
@enderror
</div>
Expand Down
88 changes: 85 additions & 3 deletions src/Cart/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Azuriom\Plugin\Shop\Cart;

use Azuriom\Plugin\Shop\Exceptions\CouponNotAllowedException;
use Azuriom\Plugin\Shop\Models\Concerns\Buyable;
use Azuriom\Plugin\Shop\Models\Coupon;
use Azuriom\Plugin\Shop\Models\Discount;
use Azuriom\Plugin\Shop\Models\Giftcard;
use Azuriom\Plugin\Shop\Models\Package;
use Illuminate\Contracts\Session\Session;
Expand Down Expand Up @@ -37,12 +39,21 @@ class Cart implements Arrayable
*/
private Collection $giftcards;

/**
* A collection of discounts applied to the cart.
*/
private Collection $packageDiscounts;

/**
* Create a new cart instance.
*/
private function __construct(Session $session = null)
private function __construct(?Session $session = null)
{
$this->session = $session;
$this->packageDiscounts = collect();

// Always add active global discounts instantly.
$this->addAppliedDiscounts(Discount::global()->active()->get());

if ($session === null) {
$this->items = collect();
Expand All @@ -58,14 +69,20 @@ private function __construct(Session $session = null)
/**
* Add an item to the cart.
*/
public function add(Buyable $buyable, int $quantity = 1, float $userPrice = null): void
public function add(Buyable $buyable, int $quantity = 1, ?float $userPrice = null): void
{
if ($quantity <= 0) {
return;
}

$cartItem = $this->get($buyable);

if ($buyable instanceof Package) {
/** @var Collection $discounts */
$discounts = $buyable->discounts()->active()->get();
$this->addAppliedDiscounts($discounts);
}

if ($cartItem === null) {
$this->set($buyable, $quantity, $userPrice);

Expand All @@ -81,7 +98,7 @@ public function add(Buyable $buyable, int $quantity = 1, float $userPrice = null
/**
* Set the quantity of an item in the cart.
*/
public function set(Buyable $buyable, int $quantity = 1, float $userPrice = null): void
public function set(Buyable $buyable, int $quantity = 1, ?float $userPrice = null): void
{
if ($quantity <= 0) {
$this->remove($buyable);
Expand Down Expand Up @@ -116,6 +133,10 @@ public function remove(Buyable $buyable): void
{
$this->items->forget($this->getItemId($buyable));

if ($buyable instanceof Package) {
$this->removeAppliedDiscounts($buyable->discounts()->active()->get());
}

$this->save();
}

Expand Down Expand Up @@ -251,9 +272,15 @@ public function coupons(): Collection

/**
* Add a coupon to the cart.
*
* @throws CouponNotAllowedException
*/
public function addCoupon(Coupon $coupon): void
{
if ($this->couponClashesWithActiveDiscount($coupon)) {
throw new CouponNotAllowedException();
}

$this->coupons->put($coupon->id, $coupon);

$this->save();
Expand Down Expand Up @@ -405,4 +432,59 @@ public function toArray(): array
'giftcards' => $this->giftcards->pluck('code')->all(),
];
}

/**
* Add discounts applied to cart.
*/
private function addAppliedDiscounts(Collection $discounts): void
{
if ($discounts->count() > 0) {
$discounts->each(function (Discount $discount) {
if (! $this->packageDiscounts->contains($discount->id)) {
$this->packageDiscounts->put($discount->id, $discount);
}
});
}
}

/**
* Remove given discounts from applied discounts collection.
* Skip global discounts as they are always applied regardless of what item
* has been placed in or removed from the cart.
*/
private function removeAppliedDiscounts(Collection $discounts): void
{
if ($discounts->count() > 0) {
$discounts->each(function (Discount $discount) {
if ($this->packageDiscounts->contains($discount->id) && ! $discount->is_global) {
$this->packageDiscounts->forget($discount->id);
}
});
}
}

/**
* Determine if the given coupon will clash with applied discounts.
*/
private function couponClashesWithActiveDiscount(Coupon $coupon): bool
{
$couponClashes = false;

if ($this->packageDiscounts->count() === 0) {
return false;
}

if (! $coupon->discount_allowed) {
return true;
}

/** @var Discount $discount */
foreach ($this->packageDiscounts as $discount) {
if (! $discount->coupons_allowed) {
$couponClashes = true;
}
}

return $couponClashes;
}
}
18 changes: 13 additions & 5 deletions src/Controllers/CouponController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Azuriom\Http\Controllers\Controller;
use Azuriom\Plugin\Shop\Cart\Cart;
use Azuriom\Plugin\Shop\Exceptions\CouponNotAllowedException;
use Azuriom\Plugin\Shop\Models\Coupon;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
Expand All @@ -17,13 +18,14 @@ class CouponController extends Controller
*/
public function add(Request $request)
{
$validated = $this->validate($request, ['code' => 'required']);
$validated = $this->validate($request, ['coupon_code' => 'required']);

$coupon = Coupon::active()->firstWhere($validated);
/** @var Coupon $coupon */
$coupon = Coupon::active()->firstWhere(['code' => $validated['coupon_code']]);

if ($coupon === null || $coupon->hasReachLimit($request->user())) {
throw ValidationException::withMessages([
'code' => trans('shop::messages.coupons.error'),
'coupon_code' => trans('shop::messages.coupons.error'),
]);
}

Expand All @@ -32,11 +34,17 @@ public function add(Request $request)
if ((! $coupon->can_cumulate && ! $cart->coupons()->isEmpty())
|| $cart->coupons()->contains('can_cumulate', false)) {
throw ValidationException::withMessages([
'code' => trans('shop::messages.coupons.cumulate'),
'coupon_code' => trans('shop::messages.coupons.cumulate'),
]);
}

$cart->addCoupon($coupon);
try {
$cart->addCoupon($coupon);
} catch (CouponNotAllowedException) {
throw ValidationException::withMessages([
'coupon_code' => trans('shop::messages.coupons.discount'),
]);
}

return to_route('shop.cart.index');
}
Expand Down
11 changes: 11 additions & 0 deletions src/Exceptions/CouponNotAllowedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Azuriom\Plugin\Shop\Exceptions;

use Exception;

final class CouponNotAllowedException extends Exception
{
}
4 changes: 3 additions & 1 deletion src/Models/Coupon.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* @property int $user_limit
* @property int $global_limit
* @property bool $can_cumulate
* @property bool $discount_allowed
* @property bool $is_enabled
* @property bool $is_global
* @property bool $is_fixed
Expand Down Expand Up @@ -42,7 +43,7 @@ class Coupon extends Model
* @var array<int, string>
*/
protected $fillable = [
'code', 'discount', 'start_at', 'expire_at', 'user_limit', 'global_limit', 'can_cumulate', 'is_enabled', 'is_global', 'is_fixed',
'code', 'discount', 'start_at', 'expire_at', 'user_limit', 'global_limit', 'can_cumulate', 'is_enabled', 'is_global', 'is_fixed', 'discount_allowed',
];

/**
Expand All @@ -57,6 +58,7 @@ class Coupon extends Model
'is_enabled' => 'boolean',
'is_global' => 'boolean',
'is_fixed' => 'boolean',
'discount_allowed' => 'boolean',
];

/**
Expand Down
4 changes: 3 additions & 1 deletion src/Models/Discount.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* @property int $id
* @property string $name
* @property int $discount
* @property bool $coupons_allowed
* @property bool $is_global
* @property bool $is_enabled
* @property \Carbon\Carbon $start_at
Expand Down Expand Up @@ -37,7 +38,7 @@ class Discount extends Model
* @var array<int, string>
*/
protected $fillable = [
'name', 'discount', 'packages', 'is_global', 'is_enabled', 'start_at', 'end_at',
'name', 'discount', 'packages', 'is_global', 'is_enabled', 'start_at', 'end_at', 'coupons_allowed',
];

/**
Expand All @@ -50,6 +51,7 @@ class Discount extends Model
'end_at' => 'datetime',
'is_global' => 'boolean',
'is_enabled' => 'boolean',
'coupons_allowed' => 'boolean',
];

/**
Expand Down
3 changes: 2 additions & 1 deletion src/Requests/CouponRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class CouponRequest extends FormRequest
* @var array<int, string>
*/
protected array $checkboxes = [
'can_cumulate', 'is_enabled', 'is_global',
'can_cumulate', 'is_enabled', 'is_global', 'discount_allowed',
];

/**
Expand Down Expand Up @@ -50,6 +50,7 @@ public function rules(): array
'is_enabled' => ['filled', 'boolean'],
'is_global' => ['filled', 'boolean'],
'is_fixed' => ['filled', 'boolean'],
'discount_allowed' => ['filled', 'boolean'],
];
}
}
Loading

0 comments on commit d24dd79

Please sign in to comment.