VYPR
Critical severity9.9NVD Advisory· Published May 29, 2026· Updated May 29, 2026

CVE-2026-47744

CVE-2026-47744

Description

Shopper is a Headless e-commerce Admin Panel. Prior to 2.8.0, two distinct authorization defects in the team settings allowed any authenticated panel user to take over the RBAC system. Settings/Team/Index had no mount() authorization. Any authenticated user could load the page and use its public actions to create new roles and delete other users, including administrators. Settings/Team/RolePermission gated its write actions on the read-only view_users permission. Any user holding view_users could grant themselves or any other user arbitrary permissions, including manage_users and edit_orders, effectively escalating to full panel administrator from a read-only account. Combined, these two defects allow a low-privilege authenticated user to obtain administrator privileges and remove the legitimate administrators from the panel. This vulnerability is fixed in 2.8.0.

AI Insight

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

Two authorization defects in Shopper admin panel allow any authenticated user to escalate to administrator and delete other users; fixed in 2.8.0.

Vulnerability

Two distinct authorization defects in the Shopper admin panel (versions prior to 2.8.0) allow any authenticated user to compromise the RBAC system. The Settings/Team/Index page lacks mount() authorization, so any authenticated user can load it and use its public actions to create new roles and delete other users, including administrators. Additionally, Settings/Team/RolePermission gates its write actions on the read-only view_users permission, meaning any user holding view_users can grant themselves or others arbitrary permissions (e.g., manage_users, edit_orders), effectively escalating to full panel administrator from a read-only account [1].

Exploitation

An attacker needs only an authenticated panel session (any role). They can first load Settings/Team/Index without authorization checks, create a new role with elevated permissions, or delete existing administrators. If the attacker already has the view_users permission, they can directly use Settings/Team/RolePermission write actions to grant themselves manage_users and other high-privilege permissions. Combining both defects, a low-privilege user can obtain administrator privileges and remove legitimate administrators from the panel [1].

Impact

Successful exploitation grants the attacker full administrator privileges over the Shopper admin panel. The attacker can delete any user, including existing administrators, and assign arbitrary permissions to themselves or others. This leads to complete compromise of the RBAC system, unauthorized access to sensitive data, and potential manipulation of e-commerce operations [1].

Mitigation

The vulnerability is fixed in Shopper version 2.8.0. The fix adds proper authorization checks: Settings/Team/Index::mount() now authorizes against manage_users, and Settings/Team/RolePermission write actions require manage_users instead of view_users. Users should upgrade immediately via composer require shopper/admin:^2.8. No workarounds are available. The CVE is not listed on the CISA Known Exploited Vulnerabilities (KEV) catalog as of publication [1].

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
  • Shopperlabs/Shopperinferred2 versions
    <2.8.0+ 1 more
    • (no CPE)range: <2.8.0
    • (no CPE)range: <2.8.0

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

No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.

References

1

News mentions

0

No linked articles in our index yet.