VYPR
Medium severity5.9NVD Advisory· Published May 29, 2026· Updated May 29, 2026

CVE-2026-47741

CVE-2026-47741

Description

Shopper is a Headless e-commerce Admin Panel. Prior to 2.8.0, CreateOrderFromCartAction::execute previously created the Order row before checking and incrementing the discount's total_use counter. Under concurrent checkout pressure (Black Friday, flash sale, viral coupon), the global usage_limit was silently exceeded: orders were committed with the discount fully applied to price_amount while the counter blocked at usage_limit. The merchant had no signal that an over-redemption had occurred. This vulnerability is fixed in 2.8.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Race condition in Shopper's CreateOrderFromCartAction allows discount usage_limit to be silently exceeded under concurrent checkout.

Vulnerability

In Shopper prior to 2.8.0, CreateOrderFromCartAction::execute creates the Order row before checking and incrementing the discount's total_use counter. Under concurrent requests, multiple orders can be committed with the discount applied while the counter remains at usage_limit. Affected versions: <2.8.0. [1][2][3]

Exploitation

An attacker needs network access to the checkout endpoint and a valid discount coupon with a usage_limit. By sending concurrent checkout requests (e.g., > usage_limit), all requests pass the initial validation without locking, then each creates an order and attempts to increment total_use. Once the limit is reached, increment() returns 0 but the code does not check the return value, so orders beyond the limit are committed. [2]

Impact

Successful exploitation results in over-redemption of discounts: orders are created with the discount fully applied even though the usage_limit has been exceeded. This causes direct financial loss to the merchant, as they grant unintended discounts. No signal is given to the merchant that over-redemption occurred. [3]

Mitigation

Fixed in version 2.8.0. The fix reserves the discount slot atomically before order creation using lockForUpdate and compare-and-swap, and throws DiscountLimitReachedException on exhaustion. Upgrade via composer require shopper/cart:^2.8 shopper/core:^2.8 and run php artisan migrate. No workarounds available. [1][3]

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
fcd0c5920588

fix(security): authorization bypass and discount race in cart/checkout (#511)

https://github.com/shopperlabs/shopperArthur MonneyMay 11, 2026via body-scan-shorthand
70 files changed · +707 43
  • packages/admin/src/Livewire/Components/Customers/Addresses.php+2 0 modified
    @@ -8,6 +8,7 @@
     use Illuminate\Database\Eloquent\Collection;
     use Illuminate\Database\Eloquent\Model;
     use Livewire\Attributes\Computed;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Address;
     use Shopper\Core\Models\Contracts\Address as AddressContract;
    @@ -19,6 +20,7 @@ class Addresses extends Component
         use HandlesAuthorizationExceptions;
     
         /** @var Model&ShopperUser */
    +    #[Locked]
         public ShopperUser $customer;
     
         /**
    
  • packages/admin/src/Livewire/Components/Customers/Orders.php+6 2 modified
    @@ -17,8 +17,11 @@
     use Filament\Tables\Table;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
    +use Shopper\Core\Enum\OrderStatus;
    +use Shopper\Core\Enum\PaymentStatus;
     use Shopper\Core\Models\Contracts\Order;
     use Shopper\Models\Contracts\ShopperUser;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -31,6 +34,7 @@ class Orders extends Component implements HasActions, HasSchemas, HasTable
         use InteractsWithTable;
     
         /** @var Model&ShopperUser */
    +    #[Locked]
         public ShopperUser $customer;
     
         public function table(Table $table): Table
    @@ -90,11 +94,11 @@ public function table(Table $table): Table
                 ->filters([
                     SelectFilter::make('status')
                         ->label(__('shopper::forms.label.status'))
    -                    ->options(\Shopper\Core\Enum\OrderStatus::class)
    +                    ->options(OrderStatus::class)
                         ->multiple(),
                     SelectFilter::make('payment_status')
                         ->label(__('shopper::forms.label.payment_status'))
    -                    ->options(\Shopper\Core\Enum\PaymentStatus::class)
    +                    ->options(PaymentStatus::class)
                         ->multiple(),
                     SelectFilter::make('zone_id')
                         ->label(__('shopper::pages/settings/zones.single'))
    
  • packages/admin/src/Livewire/Components/Customers/Profile.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Customers;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Models\Contracts\ShopperUser;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -13,6 +14,7 @@ class Profile extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public ShopperUser $customer;
     
         public function render(): View
    
  • packages/admin/src/Livewire/Components/Orders/Fulfillment.php+2 0 modified
    @@ -6,6 +6,7 @@
     
     use BackedEnum;
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Livewire\Component;
     use Shopper\Core\Enum\OrderStatus;
    @@ -18,6 +19,7 @@ class Fulfillment extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Order $order;
     
         public function hasUnfulfilledItems(): bool
    
  • packages/admin/src/Livewire/Components/Orders/OrderCustomer.php+2 0 modified
    @@ -6,6 +6,7 @@
     
     use Illuminate\Contracts\View\View;
     use Livewire\Attributes\Computed;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Contracts\Order;
     use Shopper\Models\Contracts\ShopperUser;
    @@ -18,6 +19,7 @@ class OrderCustomer extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Order $order;
     
         #[Computed]
    
  • packages/admin/src/Livewire/Components/Orders/OrderItems.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Orders;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Livewire\Component;
     use Livewire\WithPagination;
    @@ -16,6 +17,7 @@ class OrderItems extends Component
         use HandlesAuthorizationExceptions;
         use WithPagination;
     
    +    #[Locked]
         public Order $order;
     
         public int $perPage = 3;
    
  • packages/admin/src/Livewire/Components/Orders/OrderNotes.php+4 0 modified
    @@ -6,6 +6,7 @@
     
     use Filament\Notifications\Notification;
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\Validate;
     use Livewire\Component;
     use Shopper\Core\Events\Orders\OrderNoteAdded;
    @@ -16,13 +17,16 @@ class OrderNotes extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Order $order;
     
         #[Validate('required|string')]
         public ?string $notes = null;
     
         public function leaveNotes(): void
         {
    +        $this->authorize('edit_orders');
    +
             $this->validate();
     
             $this->order->update(['notes' => $this->notes]);
    
  • packages/admin/src/Livewire/Components/Orders/OrderSummary.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Orders;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Livewire\Component;
     use Shopper\Core\Models\Contracts\Order;
    @@ -15,6 +16,7 @@ class OrderSummary extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Order $order;
     
         #[On('order.updated')]
    
  • packages/admin/src/Livewire/Components/Products/Form/Attributes.php+2 0 modified
    @@ -17,6 +17,7 @@
     use Filament\Tables\Table;
     use Illuminate\Contracts\View\View;
     use Livewire\Attributes\Lazy;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Actions\Store\Product\DetachAttributesToProductAction;
    @@ -34,6 +35,7 @@ class Attributes extends Component implements HasActions, HasSchemas, HasTable
         use InteractsWithSchemas;
         use InteractsWithTable;
     
    +    #[Locked]
         public Product $product;
     
         public function placeholder(): View
    
  • packages/admin/src/Livewire/Components/Products/Form/Edit.php+2 0 modified
    @@ -212,6 +212,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('edit_products');
    +
             $this->validate();
     
             $this->product = app()->call(UpdateProductAction::class, [
    
  • packages/admin/src/Livewire/Components/Products/Form/Files.php+4 0 modified
    @@ -11,6 +11,7 @@
     use Filament\Schemas\Schema;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Contracts\Product;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -24,6 +25,7 @@ class Files extends Component implements HasSchemas
         use InteractsWithSchemas;
     
         /** @var Model&Product */
    +    #[Locked]
         public Product $product;
     
         /** @var array<string, mixed>|null */
    @@ -53,6 +55,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('edit_products');
    +
             $this->product->update($this->form->getState());
     
             $this->dispatch('product.updated');
    
  • packages/admin/src/Livewire/Components/Products/Form/Inventory.php+5 0 modified
    @@ -24,6 +24,7 @@
     use Filament\Tables\Table;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Livewire\Component;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
    @@ -43,6 +44,7 @@ class Inventory extends Component implements HasActions, HasSchemas, HasTable
         use InteractsWithTable;
     
         /** @var Model&Product */
    +    #[Locked]
         public Product $product;
     
         /** @var array<string, mixed>|null */
    @@ -72,6 +74,7 @@ public function form(Schema $schema): Schema
                                     TextInput::make('barcode')
                                         ->label(__('shopper::forms.label.barcode'))
                                         ->unique(config('shopper.models.product'), 'barcode', ignoreRecord: true)
    +                                    ->regex('/^[A-Za-z0-9\-]*$/')
                                         ->maxLength(255),
                                     TextInput::make('security_stock')
                                         ->label(__('shopper::forms.label.safety_stock'))
    @@ -194,6 +197,8 @@ public function table(Table $table): Table
     
         public function store(): void
         {
    +        $this->authorize('edit_products');
    +
             $this->product->update($this->form->getState());
     
             $this->dispatch('product.updated');
    
  • packages/admin/src/Livewire/Components/Products/Form/Media.php+2 0 modified
    @@ -11,6 +11,7 @@
     use Filament\Schemas\Schema;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Contracts\Product;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -24,6 +25,7 @@ class Media extends Component implements HasSchemas
         use InteractsWithSchemas;
     
         /** @var Model&Product */
    +    #[Locked]
         public Product $product;
     
         /** @var array<string, mixed>|null */
    
  • packages/admin/src/Livewire/Components/Products/Form/RelatedProducts.php+2 0 modified
    @@ -14,6 +14,7 @@
     use Illuminate\Database\Eloquent\Model;
     use Livewire\Attributes\Computed;
     use Livewire\Attributes\Lazy;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Core\Models\Contracts\Product;
    @@ -27,6 +28,7 @@ class RelatedProducts extends Component implements HasActions, HasSchemas
         use InteractsWithSchemas;
     
         /** @var Model&Product */
    +    #[Locked]
         public Product $product;
     
         public function mount(): void
    
  • packages/admin/src/Livewire/Components/Products/Form/Seo.php+4 0 modified
    @@ -12,6 +12,7 @@
     use Filament\Schemas\Schema;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Components\Form\SeoField;
     use Shopper\Core\Models\Contracts\Product;
    @@ -26,6 +27,7 @@ class Seo extends Component implements HasSchemas
         use InteractsWithSchemas;
     
         /** @var Model&Product */
    +    #[Locked]
         public Product $product;
     
         /** @var array<string, mixed>|null */
    @@ -51,6 +53,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('edit_products');
    +
             $this->product->update($this->form->getState());
     
             $this->dispatch('product.updated');
    
  • packages/admin/src/Livewire/Components/Products/Form/Shipping.php+4 0 modified
    @@ -11,6 +11,7 @@
     use Filament\Schemas\Schema;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Components\Form\ShippingField;
     use Shopper\Components\Section;
    @@ -26,6 +27,7 @@ class Shipping extends Component implements HasSchemas
         use InteractsWithSchemas;
     
         /** @var Model&Product */
    +    #[Locked]
         public Product $product;
     
         /** @var array<string, mixed>|null */
    @@ -55,6 +57,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('edit_products');
    +
             $this->product->update($this->form->getState());
     
             $this->dispatch('product.updated');
    
  • packages/admin/src/Livewire/Components/Products/Form/Variants.php+2 0 modified
    @@ -22,6 +22,7 @@
     use Illuminate\Support\Facades\Blade;
     use Illuminate\Support\HtmlString;
     use Livewire\Attributes\Lazy;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Core\Models\Contracts\Product;
    @@ -36,6 +37,7 @@ class Variants extends Component implements HasActions, HasSchemas, HasTable
         use InteractsWithSchemas;
         use InteractsWithTable;
     
    +    #[Locked]
         public Product $product;
     
         public function placeholder(): View
    
  • packages/admin/src/Livewire/Components/Settings/Legal/PolicyForm.php+4 0 modified
    @@ -14,6 +14,7 @@
     use Filament\Schemas\Contracts\HasSchemas;
     use Filament\Schemas\Schema;
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Legal;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -27,6 +28,7 @@ class PolicyForm extends Component implements HasActions, HasSchemas
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public Legal $legal;
     
         /**
    @@ -59,6 +61,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('access_setting');
    +
             $this->legal->update(array_merge($this->form->getState(), [
                 'slug' => $this->legal->slug,
             ]));
    
  • packages/admin/src/Livewire/Components/Settings/Legal/Privacy.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Settings\Legal;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Legal;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -13,6 +14,7 @@ class Privacy extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Legal $legal;
     
         public function render(): View
    
  • packages/admin/src/Livewire/Components/Settings/Legal/Refund.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Settings\Legal;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Legal;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -13,6 +14,7 @@ class Refund extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Legal $legal;
     
         public function render(): View
    
  • packages/admin/src/Livewire/Components/Settings/Legal/Shipping.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Settings\Legal;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Legal;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -13,6 +14,7 @@ class Shipping extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Legal $legal;
     
         public function render(): View
    
  • packages/admin/src/Livewire/Components/Settings/Legal/Terms.php+2 0 modified
    @@ -5,6 +5,7 @@
     namespace Shopper\Livewire\Components\Settings\Legal;
     
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Legal;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -13,6 +14,7 @@ class Terms extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Legal $legal;
     
         public function render(): View
    
  • packages/admin/src/Livewire/Components/Settings/Locations/InventoryForm.php+4 0 modified
    @@ -18,6 +18,7 @@
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
     use Illuminate\Support\Str;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Components\Form\AddressField;
     use Shopper\Components\Section;
    @@ -34,6 +35,7 @@ class InventoryForm extends Component implements HasActions, HasSchemas
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public Model&Inventory $inventory;
     
         /** @var array<string, mixed>|null */
    @@ -102,6 +104,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize($this->inventory->id ? 'edit_inventories' : 'add_inventories');
    +
             if ($this->inventory->id) {
                 $this->inventory->update($this->form->getState());
             } else {
    
  • packages/admin/src/Livewire/Components/Settings/Team/Permissions.php+14 2 modified
    @@ -6,6 +6,7 @@
     
     use Filament\Notifications\Notification;
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Livewire\Component;
     use Shopper\Models\Permission;
    @@ -16,6 +17,7 @@ class Permissions extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Role $role;
     
         public function mount(): void
    @@ -26,9 +28,13 @@ public function mount(): void
         public function togglePermission(int $id): void
         {
             $this->authorize('view_users');
    -        /** @var Permission $permission */
    +
             $permission = Permission::query()->find($id);
     
    +        if ($permission === null) {
    +            return;
    +        }
    +
             if ($this->role->hasPermissionTo($permission->name)) {
                 $this->role->revokePermissionTo($permission->name);
     
    @@ -50,7 +56,13 @@ public function removePermission(int $id): void
         {
             $this->authorize('view_users');
     
    -        Permission::query()->find($id)->delete();
    +        $permission = Permission::query()->find($id);
    +
    +        if ($permission === null) {
    +            return;
    +        }
    +
    +        $permission->delete();
     
             Notification::make()
                 ->title(__('shopper::notifications.delete', ['item' => __('shopper::pages/settings/staff.permission')]))
    
  • packages/admin/src/Livewire/Components/Settings/Team/UsersRole.php+2 0 modified
    @@ -16,6 +16,7 @@
     use Filament\Tables\Table;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Builder;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Models\Contracts\ShopperUser;
     use Shopper\Models\Role;
    @@ -28,6 +29,7 @@ class UsersRole extends Component implements HasActions, HasSchemas, HasTable
         use InteractsWithSchemas;
         use InteractsWithTable;
     
    +    #[Locked]
         public Role $role;
     
         public function table(Table $table): Table
    
  • packages/admin/src/Livewire/Pages/Collection/Edit.php+2 0 modified
    @@ -133,6 +133,8 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('edit_collections');
    +
             $this->collection->update($this->form->getState());
     
             Notification::make()
    
  • packages/admin/src/Livewire/Pages/Customers/Create.php+12 1 modified
    @@ -141,12 +141,23 @@ public function form(Schema $schema): Schema
     
         public function store(): void
         {
    +        $this->authorize('add_customers');
    +
             /** @var array<string, mixed> $data */
             $data = $this->form->getState();
             $sendMail = data_get($data, 'send_mail');
             $password = data_get($data, 'password_confirmation');
     
    -        $customerData = Arr::except($data, ['address', 'send_mail', 'password_confirmation']);
    +        $customerData = Arr::only($data, [
    +            'first_name',
    +            'last_name',
    +            'email',
    +            'phone_number',
    +            'gender',
    +            'password',
    +            'opt_in',
    +        ]);
    +
             $address = array_merge(Arr::only($data, ['address'])['address'], [
                 'first_name' => $data['first_name'],
                 'last_name' => $data['last_name'],
    
  • packages/admin/src/Livewire/Pages/Customers/Show.php+2 0 modified
    @@ -13,6 +13,7 @@
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
     use Illuminate\Support\Str;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\Url;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Livewire\Pages\AbstractPageComponent;
    @@ -25,6 +26,7 @@ class Show extends AbstractPageComponent implements HasActions, HasSchemas
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public ShopperUser $customer;
     
         #[Url(as: 'tab')]
    
  • packages/admin/src/Livewire/Pages/Order/Detail.php+8 0 modified
    @@ -11,6 +11,7 @@
     use Filament\Schemas\Concerns\InteractsWithSchemas;
     use Filament\Schemas\Contracts\HasSchemas;
     use Illuminate\Contracts\View\View;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Core\Enum\OrderStatus;
    @@ -30,6 +31,7 @@ class Detail extends AbstractPageComponent implements HasActions, HasSchemas
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public Order $order;
     
         public function mount(): void
    @@ -48,6 +50,7 @@ public function cancelOrderAction(): Action
         {
             return Action::make('cancelOrder')
                 ->label(__('shopper::forms.actions.cancel_order'))
    +            ->authorize('edit_orders')
                 ->visible($this->order->canBeCancelled())
                 ->action(function (): void {
                     $this->order->update([
    @@ -71,6 +74,7 @@ public function startProcessingAction(): Action
         {
             return Action::make('startProcessing')
                 ->label(__('shopper::forms.actions.start_processing'))
    +            ->authorize('edit_orders')
                 ->visible($this->order->isNew())
                 ->action(function (): void {
                     $this->order->update(['status' => OrderStatus::Processing]);
    @@ -89,6 +93,7 @@ public function markPaidAction(): Action
         {
             return Action::make('markPaid')
                 ->label(__('shopper::forms.actions.mark_paid'))
    +            ->authorize('edit_orders')
                 ->visible($this->order->isPaymentPending() || $this->order->isPaymentAuthorized())
                 ->action(function (): void {
                     $data = ['payment_status' => PaymentStatus::Paid];
    @@ -115,6 +120,7 @@ public function markCompleteAction(): Action
         {
             return Action::make('markComplete')
                 ->label(__('shopper::forms.actions.mark_complete'))
    +            ->authorize('edit_orders')
                 ->visible($this->order->isProcessing() && $this->order->isPaid())
                 ->action(function (): void {
                     $this->order->update(['status' => OrderStatus::Completed]);
    @@ -136,6 +142,7 @@ public function capturePaymentAction(): Action
             return Action::make('capturePayment')
                 ->label(__('shopper::forms.actions.capture_payment'))
                 ->icon(Untitledui::CreditCardDown)
    +            ->authorize('edit_orders')
                 ->visible($this->order->isPaymentAuthorized())
                 ->requiresConfirmation()
                 ->modalIcon(Untitledui::CreditCardDown)
    @@ -182,6 +189,7 @@ public function archiveAction(): Action
                 ->label(__('shopper::forms.actions.archive'))
                 ->color('danger')
                 ->icon(Untitledui::Archive)
    +            ->authorize('edit_orders')
                 ->visible(! $this->order->isCompleted() && ! $this->order->isPaid())
                 ->requiresConfirmation()
                 ->modalHeading(__('shopper::pages/orders.modals.archived_number', ['number' => $this->order->number]))
    
  • packages/admin/src/Livewire/Pages/Order/Shipments.php+2 0 modified
    @@ -137,6 +137,7 @@ public function table(Table $table): Table
                         ->label(__('shopper::forms.actions.mark_delivered'))
                         ->icon(Untitledui::PackageCheck)
                         ->color('success')
    +                    ->authorize('edit_orders')
                         ->visible(fn (OrderShipping $record): bool => $record->canBeDelivered())
                         ->requiresConfirmation()
                         ->action(function (OrderShipping $record): void {
    @@ -151,6 +152,7 @@ public function table(Table $table): Table
                         ->label(__('shopper::forms.actions.edit'))
                         ->icon(Untitledui::Edit03)
                         ->iconButton()
    +                    ->authorize('edit_orders')
                         ->modalWidth(Width::Large)
                         ->fillForm(fn (OrderShipping $record): array => [
                             'carrier_id' => $record->carrier_id,
    
  • packages/admin/src/Livewire/Pages/Product/Edit.php+2 0 modified
    @@ -12,6 +12,7 @@
     use Filament\Schemas\Contracts\HasSchemas;
     use Illuminate\Contracts\View\View;
     use Illuminate\Database\Eloquent\Model;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Livewire\Attributes\Url;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
    @@ -27,6 +28,7 @@ class Edit extends AbstractPageComponent implements HasActions, HasSchemas
         use InteractsWithSchemas;
     
         /** @var Model&ProductContract */
    +    #[Locked]
         public ProductContract $product;
     
         #[Url(as: 'tab')]
    
  • packages/admin/src/Livewire/Pages/Product/Variant.php+2 0 modified
    @@ -48,6 +48,7 @@ public function updateStockAction(): Action
             return Action::make('updateStock')
                 ->label(__('shopper::forms.actions.edit'))
                 ->color('gray')
    +            ->authorize('edit_products')
                 ->modalWidth(Width::Large)
                 ->fillForm([
                     'sku' => $this->variant->sku,
    @@ -67,6 +68,7 @@ public function updateStockAction(): Action
                     TextInput::make('barcode')
                         ->label(__('shopper::forms.label.barcode'))
                         ->unique(config('shopper.models.variant'), 'barcode', ignoreRecord: true)
    +                    ->regex('/^[A-Za-z0-9\-]*$/')
                         ->maxLength(255),
                 ])
                 ->action(function (array $data): void {
    
  • packages/admin/src/Livewire/Pages/Settings/Carriers.php+5 1 modified
    @@ -50,6 +50,7 @@ public function createCarrierAction(): Action
         {
             return Action::make('createCarrier')
                 ->label(__('shopper::pages/settings/carriers.add_carrier'))
    +            ->authorize('access_setting')
                 ->modalWidth(Width::ExtraLarge)
                 ->modalHeading(__('shopper::pages/settings/carriers.add_carrier'))
                 ->modalSubmitActionLabel(__('shopper::forms.actions.save'))
    @@ -88,7 +89,8 @@ public function table(Table $table): Table
                             default => 'warning',
                         }),
                     ToggleColumn::make('is_enabled')
    -                    ->label(__('shopper::forms.label.status')),
    +                    ->label(__('shopper::forms.label.status'))
    +                    ->beforeStateUpdated(fn (): mixed => $this->authorize('access_setting')),
                     TextColumn::make('updated_at')
                         ->label(__('shopper::forms.label.updated_at'))
                         ->date(),
    @@ -98,12 +100,14 @@ public function table(Table $table): Table
                         ->label(__('shopper::forms.actions.edit'))
                         ->icon(Untitledui::Edit03)
                         ->iconButton()
    +                    ->authorize('access_setting')
                         ->modalWidth(Width::ExtraLarge)
                         ->schema($this->getCarrierFormSchema())
                         ->successNotificationTitle(__('shopper::notifications.carrier.update')),
                     DeleteAction::make('delete')
                         ->label(__('shopper::forms.actions.delete'))
                         ->icon(Untitledui::Trash03)
    +                    ->authorize('access_setting')
                         ->iconButton(),
                 ])
                 ->emptyStateIcon(Untitledui::Truck)
    
  • packages/admin/src/Livewire/Pages/Settings/Currencies.php+5 1 modified
    @@ -61,13 +61,15 @@ public function table(Table $table): Table
                         ->numeric(decimalPlaces: 4)
                         ->sortable(),
                     ToggleColumn::make('is_enabled')
    -                    ->label(__('shopper::forms.label.status')),
    +                    ->label(__('shopper::forms.label.status'))
    +                    ->beforeStateUpdated(fn (): mixed => $this->authorize('access_setting')),
                 ])
                 ->recordActions([
                     EditAction::make('edit')
                         ->label(__('shopper::forms.actions.edit'))
                         ->icon(Untitledui::Edit03)
                         ->iconButton()
    +                    ->authorize('access_setting')
                         ->modalHeading(__('shopper::pages/settings/currencies.edit_rate'))
                         ->modalWidth(Width::Large)
                         ->schema([
    @@ -91,6 +93,7 @@ public function table(Table $table): Table
                         ->icon(Untitledui::CheckVerified)
                         ->modalIcon(Untitledui::CheckVerified)
                         ->modalIconColor('success')
    +                    ->authorize('access_setting')
                         ->requiresConfirmation()
                         ->action(function (Collection $records): void {
                             Currency::withoutGlobalScopes()
    @@ -108,6 +111,7 @@ public function table(Table $table): Table
                     BulkAction::make('disable')
                         ->label(__('shopper::forms.actions.disable'))
                         ->icon(Untitledui::SlashCircle01)
    +                    ->authorize('access_setting')
                         ->requiresConfirmation()
                         ->color('warning')
                         ->action(function (Collection $records): void {
    
  • packages/admin/src/Livewire/Pages/Settings/Locations/Edit.php+2 0 modified
    @@ -6,6 +6,7 @@
     
     use Illuminate\Contracts\View\View;
     use Livewire\Attributes\Layout;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Shopper\Core\Models\Inventory;
     use Shopper\Traits\HandlesAuthorizationExceptions;
    @@ -15,6 +16,7 @@ class Edit extends Component
     {
         use HandlesAuthorizationExceptions;
     
    +    #[Locked]
         public Inventory $inventory;
     
         public function mount(): void
    
  • packages/admin/src/Livewire/Pages/Settings/PaymentMethods.php+5 1 modified
    @@ -50,6 +50,7 @@ public function createPaymentAction(): Action
         {
             return Action::make('createPayment')
                 ->label(__('shopper::pages/settings/payments.add_payment'))
    +            ->authorize('access_setting')
                 ->modalWidth(Width::ExtraLarge)
                 ->modalHeading(__('shopper::pages/settings/payments.add_payment'))
                 ->modalSubmitActionLabel(__('shopper::forms.actions.save'))
    @@ -88,7 +89,8 @@ public function table(Table $table): Table
                             default => 'warning',
                         }),
                     ToggleColumn::make('is_enabled')
    -                    ->label(__('shopper::forms.label.status')),
    +                    ->label(__('shopper::forms.label.status'))
    +                    ->beforeStateUpdated(fn (): mixed => $this->authorize('access_setting')),
                     TextColumn::make('updated_at')
                         ->label(__('shopper::forms.label.updated_at'))
                         ->date(),
    @@ -98,12 +100,14 @@ public function table(Table $table): Table
                         ->label(__('shopper::forms.actions.edit'))
                         ->icon(Untitledui::Edit03)
                         ->iconButton()
    +                    ->authorize('access_setting')
                         ->modalWidth(Width::ExtraLarge)
                         ->schema($this->getPaymentFormSchema())
                         ->successNotificationTitle(__('shopper::notifications.payment.update')),
                     DeleteAction::make('delete')
                         ->label(__('shopper::forms.actions.delete'))
                         ->icon(Untitledui::Trash03)
    +                    ->authorize('access_setting')
                         ->iconButton(),
                 ])
                 ->emptyStateIcon(Untitledui::CreditCard02)
    
  • packages/admin/src/Livewire/Pages/Settings/Team/Index.php+7 0 modified
    @@ -38,6 +38,11 @@ class Index extends Component implements HasActions, HasSchemas, HasTable
         use InteractsWithSchemas;
         use InteractsWithTable;
     
    +    public function mount(): void
    +    {
    +        $this->authorize('view_users');
    +    }
    +
         public function createRoleAction(): Action
         {
             return Action::make('createRole')
    @@ -46,6 +51,7 @@ public function createRoleAction(): Action
                 ->iconButton()
                 ->outlined()
                 ->size(Size::Small)
    +            ->authorize('access_setting')
                 ->modalWidth(Width::Large)
                 ->modalHeading(__('shopper::modals.roles.new'))
                 ->modalDescription(__('shopper::modals.roles.new_description'))
    @@ -111,6 +117,7 @@ public function table(Table $table): Table
                         ->icon(Untitledui::Trash03)
                         ->iconButton()
                         ->label(__('shopper::forms.actions.delete'))
    +                    ->authorize('access_setting')
                         ->visible(fn (ShopperUser $record): bool => shopper()->auth()->user()->isAdmin() && ! $record->isAdmin()) // @phpstan-ignore-line
                         ->successNotificationTitle(__('shopper::notifications.users_roles.admin_deleted')),
                 ]);
    
  • packages/admin/src/Livewire/Pages/Settings/Team/RolePermission.php+5 5 modified
    @@ -24,6 +24,7 @@
     use Illuminate\Support\HtmlString;
     use Illuminate\Support\Str;
     use Livewire\Attributes\Layout;
    +use Livewire\Attributes\Locked;
     use Livewire\Component;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Models\Permission;
    @@ -40,6 +41,7 @@ class RolePermission extends Component implements HasActions, HasSchemas
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public Role $role;
     
         /** @var array<string, mixed>|null */
    @@ -93,6 +95,7 @@ public function generatePermissionsAction(): Action
                 ->label(__('shopper::pages/settings/staff.generate_permissions'))
                 ->icon(Untitledui::ShieldZap)
                 ->color('gray')
    +            ->authorize('access_setting')
                 ->modalWidth(Width::Medium)
                 ->modalHeading(__('shopper::pages/settings/staff.generate_permissions'))
                 ->modalDescription(__('shopper::pages/settings/staff.generate_permissions_description'))
    @@ -126,8 +129,6 @@ public function generatePermissionsAction(): Action
                         ->columnSpan('full'),
                 ])
                 ->action(function (array $data): void {
    -                $this->authorize('view_users');
    -
                     $resource = $data['resource'];
                     $group = $data['group_name'] ?? null;
     
    @@ -153,6 +154,7 @@ public function createPermissionAction(): Action
             return Action::make('createPermission')
                 ->label(__('shopper::pages/settings/staff.create_permission'))
                 ->icon(Untitledui::Lock04)
    +            ->authorize('access_setting')
                 ->modalWidth(Width::ExtraLarge)
                 ->modalHeading(__('shopper::modals.permissions.new'))
                 ->modalDescription(__('shopper::modals.permissions.new_description'))
    @@ -180,8 +182,6 @@ public function createPermissionAction(): Action
                         ->columnSpan('full'),
                 ])
                 ->action(function (array $data): void {
    -                $this->authorize('view_users');
    -
                     /** @var Permission $permission */
                     $permission = Permission::query()->create($data);
     
    @@ -198,7 +198,7 @@ public function createPermissionAction(): Action
     
         public function save(): void
         {
    -        $this->authorize('view_users');
    +        $this->authorize('access_setting');
     
             $this->role->update($this->form->getState());
     
    
  • packages/admin/src/Livewire/SlideOvers/AddProduct.php+1 0 modified
    @@ -253,6 +253,7 @@ public function form(Schema $schema): Schema
                                         TextInput::make('barcode')
                                             ->label(__('shopper::forms.label.barcode'))
                                             ->unique(config('shopper.models.product'), 'barcode')
    +                                        ->regex('/^[A-Za-z0-9\-]*$/')
                                             ->maxLength(255),
                                         TextInput::make('quantity')
                                             ->label(__('shopper::forms.label.quantity'))
    
  • packages/admin/src/Livewire/SlideOvers/AddVariant.php+1 0 modified
    @@ -182,6 +182,7 @@ public function form(Schema $schema): Schema
                                             TextInput::make('barcode')
                                                 ->label(__('shopper::forms.label.barcode'))
                                                 ->unique(shopper_table('product_variants'), 'barcode')
    +                                            ->regex('/^[A-Za-z0-9\-]*$/')
                                                 ->maxLength(255),
                                             TextInput::make('quantity')
                                                 ->label(__('shopper::forms.label.quantity'))
    
  • packages/admin/src/Livewire/SlideOvers/ChooseProductAttributes.php+2 0 modified
    @@ -24,6 +24,7 @@
     use Illuminate\Support\HtmlString;
     use JaOcero\RadioDeck\Forms\Components\RadioDeck;
     use Laravelcm\LivewireSlideOvers\SlideOverComponent;
    +use Livewire\Attributes\Locked;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Actions\Store\Product\AttachedAttributesToProductAction;
     use Shopper\Components\Separator;
    @@ -47,6 +48,7 @@ class ChooseProductAttributes extends SlideOverComponent implements HasActions,
         /** @var array<string, mixed>|null */
         public ?array $data = [];
     
    +    #[Locked]
         public Product $product;
     
         public static function panelMaxWidth(): string
    
  • packages/admin/src/Livewire/SlideOvers/CreateShippingLabel.php+2 0 modified
    @@ -23,6 +23,7 @@
     use Illuminate\Support\HtmlString;
     use Laravelcm\LivewireSlideOvers\SlideOverComponent;
     use Livewire\Attributes\Computed;
    +use Livewire\Attributes\Locked;
     use Shopper\Contracts\SlideOverForm;
     use Shopper\Core\Enum\FulfillmentStatus;
     use Shopper\Core\Enum\ShipmentStatus;
    @@ -45,6 +46,7 @@ class CreateShippingLabel extends SlideOverComponent implements HasActions, HasS
         use InteractsWithSchemas;
         use InteractsWithSlideOverForm;
     
    +    #[Locked]
         public Order $order;
     
         /** @var array<string, mixed>|null */
    
  • packages/admin/src/Livewire/SlideOvers/DiscountForm.php+2 0 modified
    @@ -27,6 +27,7 @@
     use Illuminate\Support\HtmlString;
     use Illuminate\Support\Str;
     use Laravelcm\LivewireSlideOvers\SlideOverComponent;
    +use Livewire\Attributes\Locked;
     use Shopper\Actions\Store\SaveAndDispatchDiscountAction;
     use Shopper\Components\Separator;
     use Shopper\Contracts\SlideOverForm;
    @@ -51,6 +52,7 @@ class DiscountForm extends SlideOverComponent implements HasActions, HasSchemas,
         use InteractsWithSchemas;
         use InteractsWithSlideOverForm;
     
    +    #[Locked]
         public Discount $discount;
     
         public string $action = 'store';
    
  • packages/admin/src/Livewire/SlideOvers/RelatedProductsList.php+1 0 modified
    @@ -32,6 +32,7 @@ class RelatedProductsList extends SlideOverComponent implements HasActions, HasS
         use InteractsWithSchemas;
         use InteractsWithTable;
     
    +    #[Locked]
         public Product $product;
     
         /** @var array<int> */
    
  • packages/admin/src/Livewire/SlideOvers/ReviewDetail.php+2 0 modified
    @@ -13,6 +13,7 @@
     use Filament\Support\Enums\Size;
     use Illuminate\Contracts\View\View;
     use Laravelcm\LivewireSlideOvers\SlideOverComponent;
    +use Livewire\Attributes\Locked;
     use Shopper\Core\Models\Review;
     use Shopper\Traits\HandlesAuthorizationExceptions;
     
    @@ -22,6 +23,7 @@ class ReviewDetail extends SlideOverComponent implements HasActions, HasSchemas
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public Review $review;
     
         public function mount(): void
    
  • packages/admin/src/Livewire/SlideOvers/ShipmentAddEvent.php+2 0 modified
    @@ -17,6 +17,7 @@
     use Filament\Schemas\Contracts\HasSchemas;
     use Filament\Schemas\Schema;
     use Laravelcm\LivewireSlideOvers\SlideOverComponent;
    +use Livewire\Attributes\Locked;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Contracts\SlideOverForm;
     use Shopper\Core\Actions\RecordShipmentEventAction;
    @@ -35,6 +36,7 @@ class ShipmentAddEvent extends SlideOverComponent implements HasActions, HasSche
         use InteractsWithSchemas;
         use InteractsWithSlideOverForm;
     
    +    #[Locked]
         public OrderShipping $shipment;
     
         /** @var array<array-key, mixed>|null */
    
  • packages/admin/src/Livewire/SlideOvers/ShipmentDetail.php+2 0 modified
    @@ -14,6 +14,7 @@
     use Illuminate\Database\Eloquent\Collection;
     use Laravelcm\LivewireSlideOvers\SlideOverComponent;
     use Livewire\Attributes\Computed;
    +use Livewire\Attributes\Locked;
     use Livewire\Attributes\On;
     use Mckenziearts\Icons\Untitledui\Enums\Untitledui;
     use Shopper\Core\Models\OrderShipping;
    @@ -29,6 +30,7 @@ class ShipmentDetail extends SlideOverComponent implements HasActions, HasSchema
         use InteractsWithActions;
         use InteractsWithSchemas;
     
    +    #[Locked]
         public OrderShipping $shipment;
     
         public static function panelMaxWidth(): string
    
  • packages/cart/src/Actions/CreateOrderFromCartAction.php+53 11 modified
    @@ -9,9 +9,11 @@
     use Shopper\Cart\CartManager;
     use Shopper\Cart\Events\CartCompleted;
     use Shopper\Cart\Exceptions\CartCompletedException;
    +use Shopper\Cart\Exceptions\DiscountLimitReachedException;
     use Shopper\Cart\Models\Cart;
     use Shopper\Cart\Models\CartAddress;
     use Shopper\Core\Actions\CreateOrderTaxLinesAction;
    +use Shopper\Core\Models\Contracts\Order as OrderContract;
     use Shopper\Core\Models\Contracts\ProductVariant;
     use Shopper\Core\Models\Discount;
     use Shopper\Core\Models\Order;
    @@ -35,12 +37,14 @@ public function execute(Cart $cart): Order
                     throw new CartCompletedException;
                 }
     
    +            $discount = $this->reserveDiscount($cart);
    +
                 $context = $this->cartManager->calculate($cart);
     
                 $shippingAddress = $this->createOrderAddress($cart->shippingAddress(), $cart->customer_id);
                 $billingAddress = $this->createOrderAddress($cart->billingAddress(), $cart->customer_id);
     
    -            $order = Order::query()->create([
    +            $order = resolve(OrderContract::class)::query()->create([
                     'number' => generate_number(),
                     'price_amount' => $context->total,
                     'tax_amount' => $context->taxTotal,
    @@ -50,6 +54,11 @@ public function execute(Cart $cart): Order
                     'zone_id' => $cart->zone_id,
                     'shipping_address_id' => $shippingAddress?->id,
                     'billing_address_id' => $billingAddress?->id,
    +                'discount_id' => $discount?->id,
    +                'discount_code' => $discount?->code,
    +                'discount_type' => $discount?->type->value,
    +                'discount_value_at_apply' => $discount?->value,
    +                'discount_currency_code' => $discount !== null ? $cart->currency_code : null,
                 ]);
     
                 $cart->lines->loadMorph('purchasable', [
    @@ -75,16 +84,6 @@ public function execute(Cart $cart): Order
     
                 $order->refresh();
     
    -            if ($cart->coupon_code) {
    -                Discount::query()
    -                    ->where('code', $cart->coupon_code)
    -                    ->where(function ($query): void {
    -                        $query->whereNull('usage_limit')
    -                            ->orWhereColumn('total_use', '<', 'usage_limit');
    -                    })
    -                    ->increment('total_use');
    -            }
    -
                 $cart->update(['completed_at' => now()]);
     
                 CartCompleted::dispatch($cart, $order);
    @@ -93,6 +92,49 @@ public function execute(Cart $cart): Order
             });
         }
     
    +    private function reserveDiscount(Cart $cart): ?Discount
    +    {
    +        if (! $cart->coupon_code) {
    +            return null;
    +        }
    +
    +        $discount = Discount::query()
    +            ->where('code', $cart->coupon_code)
    +            ->lockForUpdate()
    +            ->first();
    +
    +        if ($discount === null) {
    +            return null;
    +        }
    +
    +        if ($discount->usage_limit_per_user && $cart->customer_id !== null) {
    +            $alreadyRedeemed = resolve(OrderContract::class)::query()
    +                ->where('discount_id', $discount->id)
    +                ->where('customer_id', $cart->customer_id)
    +                ->exists();
    +
    +            if ($alreadyRedeemed) {
    +                throw DiscountLimitReachedException::perUser($discount->code);
    +            }
    +        }
    +
    +        $affected = Discount::query()
    +            ->whereKey($discount->id)
    +            ->where(function ($query): void {
    +                $query->whereNull('usage_limit')
    +                    ->orWhereColumn('total_use', '<', 'usage_limit');
    +            })
    +            ->increment('total_use');
    +
    +        if ($affected === 0) {
    +            throw DiscountLimitReachedException::global($discount->code);
    +        }
    +
    +        $discount->refresh();
    +
    +        return $discount;
    +    }
    +
         private function resolveItemName(Model $purchasable): string
         {
             if ($purchasable instanceof ProductVariant) {
    
  • packages/cart/src/Discounts/DiscountValidator.php+6 6 modified
    @@ -8,6 +8,7 @@
     use Shopper\Core\Enum\DiscountCondition;
     use Shopper\Core\Enum\DiscountEligibility;
     use Shopper\Core\Enum\DiscountRequirement;
    +use Shopper\Core\Models\Contracts\Order as OrderContract;
     use Shopper\Core\Models\Discount;
     
     final readonly class DiscountValidator
    @@ -31,13 +32,12 @@ public function validate(Discount $discount, CartPipelineContext $context): Disc
             }
     
             if ($discount->usage_limit_per_user && $context->cart->customer_id) {
    -            $userUses = $discount->items()
    -                ->where('condition', DiscountCondition::Eligibility)
    -                ->where('discountable_type', config('auth.providers.users.model'))
    -                ->where('discountable_id', $context->cart->customer_id)
    -                ->value('total_use') ?? 0;
    +            $alreadyRedeemed = resolve(OrderContract::class)::query()
    +                ->where('discount_id', $discount->id)
    +                ->where('customer_id', $context->cart->customer_id)
    +                ->exists();
     
    -            if ($userUses > 0) {
    +            if ($alreadyRedeemed) {
                     return new DiscountValidationResult(false, __('shopper-cart::messages.discount.already_used'));
                 }
             }
    
  • packages/cart/src/Exceptions/DiscountLimitReachedException.php+26 0 added
    @@ -0,0 +1,26 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +namespace Shopper\Cart\Exceptions;
    +
    +use RuntimeException;
    +
    +final class DiscountLimitReachedException extends RuntimeException
    +{
    +    public static function global(string $code): self
    +    {
    +        return new self(
    +            "The discount [{$code}] reached its global usage limit between cart validation and order commit. "
    +            .'No order was created.'
    +        );
    +    }
    +
    +    public static function perUser(string $code): self
    +    {
    +        return new self(
    +            "The discount [{$code}] has already been redeemed by this customer and is limited to one use per customer. "
    +            .'No order was created.'
    +        );
    +    }
    +}
    
  • packages/core/database/migrations/2026_05_06_000001_add_discount_columns_to_orders_table.php+38 0 added
    @@ -0,0 +1,38 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Illuminate\Database\Schema\Blueprint;
    +use Illuminate\Support\Facades\Schema;
    +use Shopper\Core\Helpers\Migration;
    +
    +return new class extends Migration
    +{
    +    public function up(): void
    +    {
    +        Schema::table($this->getTableName('orders'), function (Blueprint $table): void {
    +            $this->addForeignKey($table, 'discount_id', $this->getTableName('discounts'));
    +
    +            $table->after('discount_id', static function (Blueprint $table): void {
    +                $table->string('discount_code')->nullable();
    +                $table->string('discount_type', 32)->nullable();
    +                $table->unsignedInteger('discount_value_at_apply')->nullable();
    +                $table->char('discount_currency_code', 3)->nullable();
    +            });
    +        });
    +    }
    +
    +    public function down(): void
    +    {
    +        Schema::table($this->getTableName('orders'), function (Blueprint $table): void {
    +            $this->removeForeignKeyAndColumn($table, 'discount_id');
    +
    +            $table->dropColumn([
    +                'discount_code',
    +                'discount_type',
    +                'discount_value_at_apply',
    +                'discount_currency_code',
    +            ]);
    +        });
    +    }
    +};
    
  • tests/Admin/Livewire/Components/Orders/OrderNotesAuthorizationTest.php+44 0 added
    @@ -0,0 +1,44 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Core\Events\Orders\OrderNoteAdded;
    +use Shopper\Core\Models\Order;
    +use Shopper\Livewire\Components\Orders\OrderNotes;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +beforeEach(function (): void {
    +    $this->user = User::factory()->create();
    +    $this->user->givePermissionTo('read_orders');
    +    $this->actingAs($this->user);
    +});
    +
    +describe('OrderNotes authorization', function (): void {
    +    it('blocks `leaveNotes` for users without `edit_orders`', function (): void {
    +        Event::fake();
    +
    +        $order = Order::factory()->hasItems(1)->create(['notes' => 'original']);
    +
    +        Livewire::test(OrderNotes::class, ['order' => $order])
    +            ->set('notes', 'tampered')
    +            ->call('leaveNotes');
    +
    +        expect($order->fresh()->notes)->toBe('original');
    +        Event::assertNotDispatched(OrderNoteAdded::class);
    +    });
    +
    +    it('allows `leaveNotes` when user has `edit_orders`', function (): void {
    +        $this->user->givePermissionTo('edit_orders');
    +
    +        $order = Order::factory()->hasItems(1)->create(['notes' => 'original']);
    +
    +        Livewire::test(OrderNotes::class, ['order' => $order])
    +            ->set('notes', 'updated note')
    +            ->call('leaveNotes');
    +
    +        expect($order->fresh()->notes)->toBe('updated note');
    +    });
    +})->group('livewire', 'orders', 'security');
    
  • tests/Admin/Livewire/Components/Products/Form/EditTest.php+15 0 modified
    @@ -48,6 +48,21 @@
                 ->assertFormFieldIsHidden('external_id');
         });
     
    +    it('blocks `store` for users without `edit_products`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_products');
    +        $this->actingAs($unauthorized);
    +
    +        $product = Product::factory()->standard()->create(['name' => 'Original']);
    +
    +        Livewire::test(Edit::class, ['product' => $product])
    +            ->fillForm(['name' => 'Tampered'])
    +            ->call('store');
    +
    +        expect($product->fresh()->name)->toBe('Original');
    +        Event::assertNotDispatched(ProductUpdated::class);
    +    });
    +
         it('can view the external id field on external product editing', function (): void {
             config()->set('shopper.features.supplier', FeatureState::Enabled);
     
    
  • tests/Admin/Livewire/Components/Products/Form/FilesTest.php+11 0 modified
    @@ -12,6 +12,7 @@
     beforeEach(function (): void {
     
         $this->user = User::factory()->create();
    +    $this->user->givePermissionTo('edit_products');
         $this->actingAs($this->user);
     
         $this->product = Product::factory()->create();
    @@ -45,4 +46,14 @@
                 ->call('store')
                 ->assertNotified();
         });
    +
    +    it('blocks `store` for users without `edit_products`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_products');
    +        $this->actingAs($unauthorized);
    +
    +        Livewire::test(Files::class, ['product' => $this->product])
    +            ->call('store')
    +            ->assertNotDispatched('product.updated');
    +    });
     })->group('livewire', 'components', 'products');
    
  • tests/Admin/Livewire/Components/Products/Form/InventoryTest.php+15 0 modified
    @@ -13,6 +13,7 @@
     beforeEach(function (): void {
     
         $this->user = User::factory()->create();
    +    $this->user->givePermissionTo('edit_products');
         $this->actingAs($this->user);
     
         $this->inventory = InventoryModel::factory()->create(['is_default' => true]);
    @@ -55,6 +56,20 @@
                 ->and($this->product->security_stock)->toBe(5);
         });
     
    +    it('blocks `store` for users without `edit_products`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_products');
    +        $this->actingAs($unauthorized);
    +
    +        $originalSku = $this->product->sku;
    +
    +        Livewire::test(Inventory::class, ['product' => $this->product])
    +            ->fillForm(['sku' => 'TAMPERED-SKU'])
    +            ->call('store');
    +
    +        expect($this->product->fresh()->sku)->toBe($originalSku);
    +    });
    +
         it('validates unique sku', function (): void {
             $existingProduct = Product::factory()->create(['sku' => 'EXISTING-SKU']);
     
    
  • tests/Admin/Livewire/Components/Products/Form/SeoTest.php+27 0 added
    @@ -0,0 +1,27 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Livewire\Components\Products\Form\Seo;
    +use Tests\Core\Stubs\Product;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +describe(Seo::class, function (): void {
    +    it('blocks `store` for users without `edit_products`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_products');
    +        $this->actingAs($unauthorized);
    +
    +        $product = Product::factory()->create(['seo_title' => 'Original SEO']);
    +
    +        Livewire::test(Seo::class, ['product' => $product])
    +            ->fillForm(['seo_title' => 'Tampered SEO'])
    +            ->call('store')
    +            ->assertNotDispatched('product.updated');
    +
    +        expect($product->fresh()->seo_title)->toBe('Original SEO');
    +    });
    +})->group('livewire', 'components', 'products', 'security');
    
  • tests/Admin/Livewire/Components/Products/Form/ShippingTest.php+27 0 added
    @@ -0,0 +1,27 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Livewire\Components\Products\Form\Shipping;
    +use Tests\Core\Stubs\Product;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +describe(Shipping::class, function (): void {
    +    it('blocks `store` for users without `edit_products`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_products');
    +        $this->actingAs($unauthorized);
    +
    +        $product = Product::factory()->create(['weight_value' => 100]);
    +
    +        Livewire::test(Shipping::class, ['product' => $product])
    +            ->fillForm(['weight_value' => 999])
    +            ->call('store')
    +            ->assertNotDispatched('product.updated');
    +
    +        expect((int) $product->fresh()->weight_value)->toBe(100);
    +    });
    +})->group('livewire', 'components', 'products', 'security');
    
  • tests/Admin/Livewire/Components/Products/Form/VariantsTest.php+1 0 modified
    @@ -13,6 +13,7 @@
     beforeEach(function (): void {
     
         $this->user = User::factory()->create();
    +    $this->user->givePermissionTo('edit_products');
         $this->actingAs($this->user);
     
         $this->product = Product::factory()->create(['type' => ProductType::Variant]);
    
  • tests/Admin/Livewire/Components/Settings/Legal/PolicyFormTest.php+30 0 added
    @@ -0,0 +1,30 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Core\Models\Legal;
    +use Shopper\Livewire\Components\Settings\Legal\PolicyForm;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +describe(PolicyForm::class, function (): void {
    +    it('blocks `store` for users without `access_setting`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $this->actingAs($unauthorized);
    +
    +        $legal = Legal::query()->create([
    +            'title' => 'Privacy',
    +            'slug' => 'privacy',
    +            'content' => 'Original body',
    +            'is_enabled' => true,
    +        ]);
    +
    +        Livewire::test(PolicyForm::class, ['legal' => $legal])
    +            ->fillForm(['content' => 'Tampered body'])
    +            ->call('store');
    +
    +        expect($legal->fresh()->content)->toBe('Original body');
    +    });
    +})->group('livewire', 'components', 'settings', 'security');
    
  • tests/Admin/Livewire/Components/Settings/Locations/InventoryFormTest.php+40 0 added
    @@ -0,0 +1,40 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Core\Models\Inventory;
    +use Shopper\Livewire\Components\Settings\Locations\InventoryForm;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +describe(InventoryForm::class, function (): void {
    +    it('blocks `store` when editing for users without `edit_inventories`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_inventories');
    +        $this->actingAs($unauthorized);
    +
    +        $inventory = Inventory::factory()->create(['name' => 'Original Location']);
    +
    +        Livewire::test(InventoryForm::class, ['inventory' => $inventory])
    +            ->fillForm(['name' => 'Tampered Location'])
    +            ->call('store');
    +
    +        expect($inventory->fresh()->name)->toBe('Original Location');
    +    });
    +
    +    it('blocks `store` when creating for users without `add_inventories`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_inventories');
    +        $this->actingAs($unauthorized);
    +
    +        $countBefore = Inventory::query()->count();
    +
    +        Livewire::test(InventoryForm::class, ['inventory' => new Inventory])
    +            ->fillForm(['name' => 'Sneaky Location', 'email' => 'a@b.c'])
    +            ->call('store');
    +
    +        expect(Inventory::query()->count())->toBe($countBefore);
    +    });
    +})->group('livewire', 'components', 'settings', 'security');
    
  • tests/Admin/Livewire/Pages/Collection/EditTest.php+11 0 modified
    @@ -59,6 +59,17 @@
             expect($collection->refresh()->name)->toBe($newName);
         });
     
    +    it('forbids access for users without `edit_collections`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $unauthorized->givePermissionTo('browse_collections');
    +        $this->actingAs($unauthorized);
    +
    +        $collection = Collection::factory()->create();
    +
    +        Livewire::test(Edit::class, ['collection' => $collection])
    +            ->assertForbidden();
    +    });
    +
         it('cannot change type of collection on edit form', function (): void {
             $collection = Collection::factory(['type' => CollectionType::Manual])->create();
     
    
  • tests/Admin/Livewire/Pages/Customers/CreateTest.php+8 0 modified
    @@ -149,6 +149,14 @@
                 ->assertHasFormErrors(['email' => 'unique']);
         });
     
    +    it('forbids access for users without `add_customers`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $this->actingAs($unauthorized);
    +
    +        Livewire::test(Create::class)
    +            ->assertForbidden();
    +    });
    +
         it('validates password confirmation', function (): void {
             Livewire::test(Create::class)
                 ->fillForm([
    
  • tests/Admin/Livewire/Pages/Order/DetailAuthorizationTest.php+74 0 added
    @@ -0,0 +1,74 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Core\Enum\OrderStatus;
    +use Shopper\Core\Enum\PaymentStatus;
    +use Shopper\Core\Enum\ShippingStatus;
    +use Shopper\Core\Events\Orders;
    +use Shopper\Core\Models\Order;
    +use Shopper\Livewire\Pages\Order\Detail;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +beforeEach(function (): void {
    +    $this->user = User::factory()->create();
    +    $this->user->givePermissionTo('read_orders');
    +    $this->actingAs($this->user);
    +});
    +
    +describe('Detail authorization', function (): void {
    +    it('hides mutating actions for users without `edit_orders`', function (): void {
    +        $order = Order::factory()->hasItems(1)->create([
    +            'status' => OrderStatus::New,
    +            'shipping_status' => ShippingStatus::Unfulfilled,
    +            'payment_status' => PaymentStatus::Pending,
    +        ]);
    +
    +        Livewire::test(Detail::class, ['order' => $order])
    +            ->assertActionHidden('cancelOrder')
    +            ->assertActionHidden('startProcessing')
    +            ->assertActionHidden('markPaid')
    +            ->assertActionHidden('archive');
    +    });
    +
    +    it('hides `markComplete` for users without `edit_orders`', function (): void {
    +        $order = Order::factory()->hasItems(1)->create([
    +            'status' => OrderStatus::Processing,
    +            'payment_status' => PaymentStatus::Paid,
    +        ]);
    +
    +        Livewire::test(Detail::class, ['order' => $order])
    +            ->assertActionHidden('markComplete');
    +    });
    +
    +    it('hides `capturePayment` for users without `edit_orders`', function (): void {
    +        $order = Order::factory()->hasItems(1)->create([
    +            'status' => OrderStatus::Processing,
    +            'payment_status' => PaymentStatus::Authorized,
    +        ]);
    +
    +        Livewire::test(Detail::class, ['order' => $order])
    +            ->assertActionHidden('capturePayment');
    +
    +        expect($order->fresh()->payment_status)->toBe(PaymentStatus::Authorized);
    +    });
    +
    +    it('allows mutating actions when user has `edit_orders`', function (): void {
    +        Event::fake();
    +        $this->user->givePermissionTo('edit_orders');
    +
    +        $order = Order::factory()->hasItems(1)->create([
    +            'status' => OrderStatus::New,
    +            'payment_status' => PaymentStatus::Pending,
    +        ]);
    +
    +        Livewire::test(Detail::class, ['order' => $order])
    +            ->callAction('markPaid');
    +
    +        expect($order->fresh()->payment_status)->toBe(PaymentStatus::Paid);
    +        Event::assertDispatched(Orders\OrderPaid::class);
    +    });
    +})->group('livewire', 'orders', 'security');
    
  • tests/Admin/Livewire/Pages/Order/DetailTest.php+1 1 modified
    @@ -15,7 +15,7 @@
     
     beforeEach(function (): void {
         $this->user = User::factory()->create();
    -    $this->user->givePermissionTo('read_orders');
    +    $this->user->givePermissionTo(['read_orders', 'edit_orders']);
         $this->actingAs($this->user);
     });
     
    
  • tests/Admin/Livewire/Pages/Order/ShipmentsAuthorizationTest.php+29 0 added
    @@ -0,0 +1,29 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +use Livewire\Livewire;
    +use Shopper\Core\Enum\ShipmentStatus;
    +use Shopper\Core\Models\OrderShipping;
    +use Shopper\Livewire\Pages\Order\Shipments;
    +use Tests\Core\Stubs\User;
    +
    +uses(Tests\Admin\TestCase::class);
    +
    +beforeEach(function (): void {
    +    $this->user = User::factory()->create();
    +    $this->user->givePermissionTo('browse_orders');
    +    $this->actingAs($this->user);
    +});
    +
    +describe('Shipments authorization', function (): void {
    +    it('hides `markDelivered` and `edit` table actions for users without `edit_orders`', function (): void {
    +        $shipment = OrderShipping::factory()->create([
    +            'status' => ShipmentStatus::OutForDelivery,
    +        ]);
    +
    +        Livewire::test(Shipments::class)
    +            ->assertTableActionHidden('markDelivered', $shipment)
    +            ->assertTableActionHidden('edit', $shipment);
    +    });
    +})->group('livewire', 'orders', 'security');
    
  • tests/Admin/Livewire/Pages/Settings/PaymentMethodsTest.php+8 0 modified
    @@ -90,4 +90,12 @@
     
             expect(PaymentMethod::query()->find($payment->id))->toBeNull();
         });
    +
    +    it('forbids mount for users without `access_setting`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $this->actingAs($unauthorized);
    +
    +        Livewire::test(PaymentMethods::class)
    +            ->assertForbidden();
    +    });
     })->group('livewire', 'settings', 'payment');
    
  • tests/Admin/Livewire/Pages/Settings/Team/IndexTest.php+9 1 modified
    @@ -11,7 +11,7 @@
     
     beforeEach(function (): void {
         $this->user = User::factory()->create();
    -    $this->user->givePermissionTo('view_users');
    +    $this->user->givePermissionTo(['view_users', 'access_setting']);
         $this->actingAs($this->user);
     });
     
    @@ -71,6 +71,14 @@
                 ->assertHasFormErrors(['name' => 'unique']);
         });
     
    +    it('forbids mount for users without `view_users`', function (): void {
    +        $unauthorized = User::factory()->create();
    +        $this->actingAs($unauthorized);
    +
    +        Livewire::test(Index::class)
    +            ->assertForbidden();
    +    });
    +
         it('allows optional display_name and description when creating role', function (): void {
             $initialCount = Role::query()->count();
     
    
  • tests/Admin/Livewire/Pages/Settings/Team/RolePermissionTest.php+1 1 modified
    @@ -12,7 +12,7 @@
     
     beforeEach(function (): void {
         $this->user = User::factory()->create();
    -    $this->user->givePermissionTo('view_users');
    +    $this->user->givePermissionTo(['view_users', 'access_setting']);
         $this->actingAs($this->user);
     
         $this->role = Role::create([
    
  • tests/Cart/Feature/CreateOrderFromCartTest.php+72 3 modified
    @@ -7,6 +7,7 @@
     use Shopper\Cart\CartManager;
     use Shopper\Cart\Events\CartCompleted;
     use Shopper\Cart\Exceptions\CartCompletedException;
    +use Shopper\Cart\Exceptions\DiscountLimitReachedException;
     use Shopper\Cart\Models\Cart;
     use Shopper\Core\Enum\AddressType;
     use Shopper\Core\Enum\DiscountEligibility;
    @@ -135,8 +136,8 @@
             expect($discount->refresh()->total_use)->toBe(1);
         });
     
    -    it('does not increment discount usage when limit is reached', function (): void {
    -        $discount = Discount::factory()->create([
    +    it('throws and refuses to create the order when the discount global limit is reached', function (): void {
    +        Discount::factory()->create([
                 'code' => 'LIMITED',
                 'is_active' => true,
                 'type' => DiscountType::Percentage,
    @@ -150,9 +151,77 @@
             $this->cartManager->add($this->cart, $this->product);
             $this->cartManager->applyCoupon($this->cart, 'LIMITED');
     
    +        $ordersBefore = Order::query()->count();
    +
    +        try {
    +            $this->action->execute($this->cart->refresh());
    +            $this->fail('Expected DiscountLimitReachedException was not thrown.');
    +        } catch (DiscountLimitReachedException) {
    +            // expected
    +        }
    +
    +        expect(Order::query()->count())->toBe($ordersBefore)
    +            ->and($this->cart->refresh()->isCompleted())->toBeFalse();
    +    });
    +
    +    it('snapshots the discount fields onto the order', function (): void {
    +        $discount = Discount::factory()->create([
    +            'code' => 'SNAP10',
    +            'is_active' => true,
    +            'type' => DiscountType::Percentage,
    +            'value' => 10,
    +            'total_use' => 0,
    +            'usage_limit' => null,
    +            'eligibility' => DiscountEligibility::Everyone,
    +            'min_required' => DiscountRequirement::None,
    +        ]);
    +
    +        $this->cartManager->add($this->cart, $this->product);
    +        $this->cartManager->applyCoupon($this->cart, 'SNAP10');
    +
    +        $order = $this->action->execute($this->cart->refresh());
    +
    +        expect($order->discount_id)->toBe($discount->id)
    +            ->and($order->discount_code)->toBe('SNAP10')
    +            ->and($order->discount_type)->toBe(DiscountType::Percentage->value)
    +            ->and($order->discount_value_at_apply)->toBe(10)
    +            ->and($order->discount_currency_code)->toBe('USD');
    +    });
    +
    +    it('rejects a second redemption when the discount is limited to one use per customer', function (): void {
    +        $discount = Discount::factory()->create([
    +            'code' => 'ONCE',
    +            'is_active' => true,
    +            'type' => DiscountType::Percentage,
    +            'value' => 10,
    +            'total_use' => 0,
    +            'usage_limit' => null,
    +            'usage_limit_per_user' => true,
    +            'eligibility' => DiscountEligibility::Everyone,
    +            'min_required' => DiscountRequirement::None,
    +        ]);
    +
    +        $this->cartManager->add($this->cart, $this->product);
    +        $this->cartManager->applyCoupon($this->cart, 'ONCE');
    +
             $this->action->execute($this->cart->refresh());
     
    -        expect($discount->refresh()->total_use)->toBe(5);
    +        $secondCart = Cart::query()->create([
    +            'currency_code' => 'USD',
    +            'customer_id' => $this->user->id,
    +        ]);
    +        $this->cartManager->add($secondCart, $this->product);
    +        $this->cartManager->applyCoupon($secondCart, 'ONCE');
    +
    +        try {
    +            $this->action->execute($secondCart->refresh());
    +            $this->fail('Expected DiscountLimitReachedException was not thrown.');
    +        } catch (DiscountLimitReachedException) {
    +            // expected
    +        }
    +
    +        expect($discount->refresh()->total_use)->toBe(1)
    +            ->and(Order::query()->where('discount_id', $discount->id)->count())->toBe(1);
         });
     
         it('throws `CartCompletedException` for already completed cart', function (): void {
    
  • tests/Cart/Feature/DiscountValidatorTest.php+6 7 modified
    @@ -6,14 +6,13 @@
     use Shopper\Cart\Models\Cart;
     use Shopper\Cart\Pipelines\CartPipelineContext;
     use Shopper\Core\Enum\DiscountApplyTo;
    -use Shopper\Core\Enum\DiscountCondition;
     use Shopper\Core\Enum\DiscountEligibility;
     use Shopper\Core\Enum\DiscountRequirement;
     use Shopper\Core\Enum\DiscountType;
     use Shopper\Core\Models\Currency;
     use Shopper\Core\Models\Discount;
    -use Shopper\Core\Models\DiscountDetail;
     use Shopper\Core\Models\Inventory;
    +use Shopper\Core\Models\Order;
     use Shopper\Core\Models\Product;
     use Shopper\Core\Models\Zone;
     use Tests\Core\Stubs\User;
    @@ -124,12 +123,12 @@ function validDiscountAttributes(): array
                 'usage_limit_per_user' => true,
             ]));
     
    -        DiscountDetail::query()->create([
    +        Order::query()->create([
    +            'number' => 'ORD-PERUSER-1',
    +            'price_amount' => 1000,
    +            'currency_code' => 'USD',
    +            'customer_id' => $this->user->id,
                 'discount_id' => $discount->id,
    -            'condition' => DiscountCondition::Eligibility,
    -            'discountable_type' => $this->user->getMorphClass(),
    -            'discountable_id' => $this->user->id,
    -            'total_use' => 1,
             ]);
     
             $result = $this->validator->validate($discount, $this->context);
    

Vulnerability mechanics

Root cause

"Missing atomic compare-and-swap on the discount usage counter before committing the order row allows a TOCTOU race condition that silently exceeds the configured usage_limit."

Attack vector

An attacker (or a crowd of legitimate customers) triggers the bug by submitting concurrent checkout requests with the same coupon code during high-traffic periods such as Black Friday or a flash sale. All N requests pass the `DiscountValidator` check (which reads `total_use < usage_limit` without locking), then simultaneously enter `CreateOrderFromCartAction::execute`. Each request creates an Order row first, then attempts to increment `total_use`; once the counter hits `usage_limit`, the conditional `WHERE` clause stops matching and `increment()` silently returns 0, but the code does not check that return value. Orders beyond the limit are committed with the discount applied while the counter remains stuck at `usage_limit`. [ref_id=1]

Affected code

The race condition resides in `packages/cart/src/Actions/CreateOrderFromCartAction.php` (the `execute` method) which creates the Order row before atomically checking and incrementing the discount's `total_use` counter. A secondary bug in `packages/cart/src/Discounts/DiscountValidator.php` reads `DiscountDetail.total_use` to enforce `usage_limit_per_user`, but that counter is never incremented anywhere in the codebase, making the per-user limit a no-op. [ref_id=1]

What the fix does

The patch [patch_id=3105904] restructures the transaction to reserve the discount usage slot **before** creating the Order row, using a compare-and-swap pattern. It adds `lockForUpdate()` on the discount row, checks the per-user limit by querying existing orders for the same customer and discount, then atomically increments `total_use` with the conditional `WHERE total_use < usage_limit`. If the increment affects zero rows, a dedicated exception is thrown and the transaction rolls back, preventing any order from being committed. This closes both the global race condition and the silent per-user bypass. [ref_id=1]

Preconditions

  • configA discount with a finite usage_limit must be active and applied to the cart
  • inputThe attacker must be able to submit concurrent checkout requests (N > usage_limit) for the same coupon code
  • authNo authentication is required beyond normal customer checkout flow
  • networkThe attack is network-based, exploiting race conditions in HTTP request handling

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.