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(expand)+ 1 more
- (no CPE)
- (no CPE)range: <2.8.0
Patches
1fcd0c5920588fix(security): authorization bypass and discount race in cart/checkout (#511)
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
3News mentions
0No linked articles in our index yet.