+ @livewire('hub.components.discounts.create', [
+ 'discount' => $discount,
+ ])
+
diff --git a/packages/admin/resources/views/livewire/pages/discounts/index.blade.php b/packages/admin/resources/views/livewire/pages/discounts/index.blade.php
new file mode 100644
index 0000000000..b9b06a8199
--- /dev/null
+++ b/packages/admin/resources/views/livewire/pages/discounts/index.blade.php
@@ -0,0 +1,3 @@
+
+ @livewire('hub.components.discounts.show', [
+ 'discount' => $discount,
+ ])
+
diff --git a/packages/admin/resources/views/partials/availability.blade.php b/packages/admin/resources/views/partials/availability.blade.php
index e81c90c786..c00435d2f2 100644
--- a/packages/admin/resources/views/partials/availability.blade.php
+++ b/packages/admin/resources/views/partials/availability.blade.php
@@ -48,7 +48,9 @@
- @include('adminhub::partials.availability.customer-groups')
+ @include('adminhub::partials.availability.customer-groups', [
+ 'customerGroupType' => $customerGroupType ?? 'select',
+ ])
@endif
diff --git a/packages/admin/resources/views/partials/availability/customer-groups.blade.php b/packages/admin/resources/views/partials/availability/customer-groups.blade.php
index 7a3c20eb95..ae4e225a25 100644
--- a/packages/admin/resources/views/partials/availability/customer-groups.blade.php
+++ b/packages/admin/resources/views/partials/availability/customer-groups.blade.php
@@ -4,7 +4,9 @@
- @if($availability['customerGroups'][$group->id]['status'] != 'hidden')
+ {{-- availability.customerGroups.{{ $group->id }}.enabled --}}
+
+ @if($availability['customerGroups'][$group->id]['status'] != 'hidden' || ($availability['customerGroups'][$group->id]['enabled'] ?? false))
@if($startDate = $availability['customerGroups'][$group->id]['starts_at'])
@if($endDate = $availability['customerGroups'][$group->id]['ends_at'])
@@ -83,19 +85,24 @@ class="text-sm text-gray-500 hover:text-gray-800"
{{ __('adminhub::partials.orders.totals.shipping_total') }}
{{ $order->shipping_total->formatted }}
diff --git a/packages/admin/routes/includes/discounts.php b/packages/admin/routes/includes/discounts.php
new file mode 100644
index 0000000000..0628458d60
--- /dev/null
+++ b/packages/admin/routes/includes/discounts.php
@@ -0,0 +1,17 @@
+ 'can:catalogue:manage-discounts',
+], function () {
+ Route::get('/', DiscountsIndex::class)->name('hub.discounts.index');
+ Route::get('create', DiscountCreate::class)->name('hub.discounts.create');
+ Route::get('{discount}', DiscountShow::class)->name('hub.discounts.show');
+});
diff --git a/packages/admin/routes/web.php b/packages/admin/routes/web.php
index ca905eb10f..9ed0da22be 100644
--- a/packages/admin/routes/web.php
+++ b/packages/admin/routes/web.php
@@ -57,6 +57,10 @@
'prefix' => 'customers',
], __DIR__.'/includes/customers.php');
+ Route::group([
+ 'prefix' => 'discounts',
+ ], __DIR__.'/includes/discounts.php');
+
Route::group([
'prefix' => 'brands',
], __DIR__.'/includes/brands.php');
diff --git a/packages/admin/src/AdminHubServiceProvider.php b/packages/admin/src/AdminHubServiceProvider.php
index f8e3d4f566..3bd64ce340 100644
--- a/packages/admin/src/AdminHubServiceProvider.php
+++ b/packages/admin/src/AdminHubServiceProvider.php
@@ -27,12 +27,19 @@
use Lunar\Hub\Http\Livewire\Components\Collections\CollectionGroupsIndex;
use Lunar\Hub\Http\Livewire\Components\Collections\CollectionShow;
use Lunar\Hub\Http\Livewire\Components\Collections\CollectionTree;
+use Lunar\Hub\Http\Livewire\Components\Collections\CollectionTreeSelect;
use Lunar\Hub\Http\Livewire\Components\Collections\SideMenu;
use Lunar\Hub\Http\Livewire\Components\CollectionSearch;
use Lunar\Hub\Http\Livewire\Components\CurrentStaffName;
use Lunar\Hub\Http\Livewire\Components\Customers\CustomerShow;
use Lunar\Hub\Http\Livewire\Components\Customers\CustomersIndex;
use Lunar\Hub\Http\Livewire\Components\Customers\CustomersTable;
+use Lunar\Hub\Http\Livewire\Components\Discounts\DiscountCreate;
+use Lunar\Hub\Http\Livewire\Components\Discounts\DiscountShow;
+use Lunar\Hub\Http\Livewire\Components\Discounts\DiscountsIndex;
+use Lunar\Hub\Http\Livewire\Components\Discounts\DiscountsTable;
+use Lunar\Hub\Http\Livewire\Components\Discounts\Types\BuyXGetY;
+use Lunar\Hub\Http\Livewire\Components\Discounts\Types\Discount as TypesDiscount;
use Lunar\Hub\Http\Livewire\Components\Orders\EmailNotification;
use Lunar\Hub\Http\Livewire\Components\Orders\OrderCapture;
use Lunar\Hub\Http\Livewire\Components\Orders\OrderRefund;
@@ -250,6 +257,7 @@ protected function registerLivewireComponents()
$this->registerSettingsComponents();
$this->registerOrderComponents();
$this->registerCustomerComponents();
+ $this->registerDiscountComponents();
// Blade Components
Blade::componentNamespace('Lunar\\Hub\\Views\\Components', 'hub');
@@ -361,6 +369,7 @@ protected function registerCollectionComponents()
Livewire::component('hub.components.collections.collection-groups.show', CollectionGroupShow::class);
Livewire::component('hub.components.collections.show', CollectionShow::class);
Livewire::component('hub.components.collections.collection-tree', CollectionTree::class);
+ Livewire::component('hub.components.collections.collection-tree-select', CollectionTreeSelect::class);
}
/**
@@ -434,6 +443,17 @@ protected function registerSettingsComponents()
Livewire::component('hub.components.settings.taxes.tax-classes.index', TaxClassesIndex::class);
}
+ public function registerDiscountComponents()
+ {
+ Livewire::component('hub.components.discounts.index', DiscountsIndex::class);
+ Livewire::component('hub.components.discounts.show', DiscountShow::class);
+ Livewire::component('hub.components.discounts.create', DiscountCreate::class);
+ Livewire::component('hub.components.discounts.table', DiscountsTable::class);
+
+ Livewire::component('lunar.hub.http.livewire.components.discounts.types.discount', TypesDiscount::class);
+ Livewire::component('lunar.hub.http.livewire.components.discounts.types.buy-x-get-y', BuyXGetY::class);
+ }
+
/**
* Register our publishables.
*
diff --git a/packages/admin/src/Auth/Manifest.php b/packages/admin/src/Auth/Manifest.php
index 6245d2c6cc..7970a45936 100644
--- a/packages/admin/src/Auth/Manifest.php
+++ b/packages/admin/src/Auth/Manifest.php
@@ -139,6 +139,11 @@ protected function getBasePermissions(): array
'catalogue:manage-customers',
__('adminhub::auth.permissions.catalogue.customers.description')
),
+ new Permission(
+ __('adminhub::auth.permissions.discounts.name'),
+ 'catalogue:manage-discounts',
+ __('adminhub::auth.permissions.discounts.description')
+ ),
];
}
}
diff --git a/packages/admin/src/Editing/DiscountTypes.php b/packages/admin/src/Editing/DiscountTypes.php
new file mode 100644
index 0000000000..3f46161be5
--- /dev/null
+++ b/packages/admin/src/Editing/DiscountTypes.php
@@ -0,0 +1,27 @@
+ TypesDiscount::class,
+ BuyXGetY::class => TypesBuyXGetY::class,
+ ];
+
+ public function getComponent($type)
+ {
+ $component = $this->mapping[$type] ?? null;
+
+ if (! $component) {
+ return null;
+ }
+
+ return app($component);
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Collections/CollectionTreeSelect.php b/packages/admin/src/Http/Livewire/Components/Collections/CollectionTreeSelect.php
new file mode 100644
index 0000000000..676bc69b0e
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Collections/CollectionTreeSelect.php
@@ -0,0 +1,61 @@
+collectionGroupId = $this->collectionGroups->first()?->id;
+ }
+
+ public function getCollectionGroupsProperty()
+ {
+ return CollectionGroup::get();
+ }
+
+ public function toggleSelected()
+ {
+ $this->showOnlySelected = ! $this->showOnlySelected;
+ }
+
+ public function getCollectionsProperty()
+ {
+ if ($this->showOnlySelected) {
+ return Collection::whereIn('id', $this->selectedCollections)->get()->toTree();
+ }
+
+ if ($this->searchTerm) {
+ return Collection::search($this->searchTerm)->get();
+ }
+
+ return Collection::inGroup($this->collectionGroupId)->get()->toTree();
+ }
+
+ public function updatedSelectedCollections($val)
+ {
+ $this->emitUp('collectionTreeSelect.updated', $val);
+ }
+
+ public function render()
+ {
+ return view('adminhub::livewire.components.collections.collection-tree-select');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php b/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php
new file mode 100644
index 0000000000..45696053d0
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/AbstractDiscount.php
@@ -0,0 +1,390 @@
+emit('parentComponentErrorBag', $this->getErrorBag());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected $listeners = [
+ 'discountData.updated' => 'syncDiscountData',
+ 'discount.conditions' => 'syncConditions',
+ 'discount.rewards' => 'syncRewards',
+ 'discount.purchasables' => 'syncPurchasables',
+ 'collectionTreeSelect.updated' => 'selectCollections',
+ ];
+
+ public function mount()
+ {
+ $this->currency = Currency::getDefault();
+ $this->selectedBrands = $this->discount->brands->pluck('id')->toArray();
+ $this->selectedCollections = $this->discount->collections->pluck('id')->toArray();
+
+ $this->selectedConditions = $this->discount->purchasableConditions()
+ ->wherePurchasableType(Product::class)
+ ->pluck('purchasable_id')->values()->toArray();
+
+ $this->selectedRewards = $this->discount->purchasableRewards()
+ ->wherePurchasableType(Product::class)
+ ->pluck('purchasable_id')->values()->toArray();
+
+ $this->syncAvailability();
+ }
+
+ public function syncConditions($conditions)
+ {
+ $this->selectedConditions = $conditions;
+ }
+
+ public function getValidationMessages()
+ {
+ return $this->getDiscountComponent()->getValidationMessages();
+ }
+
+ /**
+ * Get the collection attribute data.
+ *
+ * @return void
+ */
+ public function getAttributeDataProperty()
+ {
+ return $this->discount->attribute_data;
+ }
+
+ /**
+ * Set the currency using the provided id.
+ *
+ * @param int|string $currencyId
+ * @return void
+ */
+ public function setCurrency($currencyId)
+ {
+ $this->currency = $this->currencies->first(fn ($currency) => $currency->id == $currencyId);
+ }
+
+ /**
+ * Return the available discount types.
+ *
+ * @return array
+ */
+ public function getDiscountTypesProperty()
+ {
+ return Discounts::getTypes();
+ }
+
+ /**
+ * Return the component for the selected discount type.
+ *
+ * @return Component
+ */
+ public function getDiscountComponent()
+ {
+ return (new DiscountTypes)->getComponent($this->discount->type);
+ }
+
+ /**
+ * Sync the discount data with what's provided.
+ *
+ * @param array $data
+ * @return void
+ */
+ public function syncDiscountData(array $data)
+ {
+ $this->discount->data = array_merge(
+ $this->discount->data,
+ $data
+ );
+ }
+
+ /**
+ * Select collections given an array of IDs
+ *
+ * @param array $ids
+ * @return void
+ */
+ public function selectCollections(array $ids)
+ {
+ $this->selectedCollections = $ids;
+ }
+
+ public function syncRewards(array $ids)
+ {
+ $this->selectedRewards = $ids;
+ }
+
+ public function syncAvailability()
+ {
+ $this->availability = [
+ 'channels' => $this->channels->mapWithKeys(function ($channel) {
+ $discountChannel = $this->discount->channels->first(fn ($assoc) => $assoc->id == $channel->id);
+
+ return [
+ $channel->id => [
+ 'channel_id' => $channel->id,
+ 'starts_at' => $discountChannel ? $discountChannel->pivot->starts_at : null,
+ 'ends_at' => $discountChannel ? $discountChannel->pivot->ends_at : null,
+ 'enabled' => $discountChannel ? $discountChannel->pivot->enabled : false,
+ 'scheduling' => false,
+ ],
+ ];
+ }),
+ 'customerGroups' => $this->customerGroups->mapWithKeys(function ($group) {
+ // $productGroup = $this->product->customerGroups->where('id', $group->id)->first();
+
+ // $pivot = $productGroup->pivot ?? null;
+
+ $pivot = null;
+
+ return [
+ $group->id => [
+ 'customer_group_id' => $group->id,
+ 'scheduling' => false,
+ 'enabled' => false,
+ 'status' => 'hidden',
+ 'starts_at' => $pivot?->starts_at ?? null,
+ 'ends_at' => $pivot?->ends_at ?? null,
+ ],
+ ];
+ }),
+ ];
+ }
+
+ /**
+ * Remove the collection by it's index.
+ *
+ * @param int|string $index
+ * @return void
+ */
+ public function removeCollection($index)
+ {
+ $this->collections->forget($index);
+ }
+
+ /**
+ * Return a list of available countries.
+ *
+ * @return Collection
+ */
+ public function getBrandsProperty()
+ {
+ return Brand::orderBy('name')->get();
+ }
+
+ /**
+ * Return the category tree.
+ *
+ * @return Collection
+ */
+ public function getCollectionTreeProperty()
+ {
+ return ModelsCollection::get()->toTree();
+ }
+
+ /**
+ * Save the discount.
+ *
+ * @return RedirectResponse
+ */
+ public function save()
+ {
+ $redirect = ! $this->discount->id;
+
+ $this->withValidator(function (Validator $validator) {
+ $validator->after(function ($validator) {
+ if ($validator->errors()->count()) {
+ $this->notify(
+ __('adminhub::validation.generic'),
+ level: 'error'
+ );
+ }
+ });
+ })->validate(null, $this->getValidationMessages());
+
+ DB::transaction(function () {
+ $this->discount->max_uses = $this->discount->max_uses ?: null;
+ $this->discount->save();
+
+ $this->discount->brands()->sync(
+ $this->selectedBrands
+ );
+
+ $channels = collect($this->availability['channels'])->mapWithKeys(function ($channel) {
+ return [
+ $channel['channel_id'] => [
+ 'starts_at' => ! $channel['enabled'] ? null : $channel['starts_at'],
+ 'ends_at' => ! $channel['enabled'] ? null : $channel['ends_at'],
+ 'enabled' => $channel['enabled'],
+ ],
+ ];
+ });
+
+ $cgAvailability = collect($this->availability['customerGroups'])->mapWithKeys(function ($group) {
+ $data = Arr::only($group, ['starts_at', 'ends_at']);
+
+ $data['visible'] = in_array($group['status'], ['purchasable', 'visible']);
+ $data['enabled'] = $group['enabled'];
+
+ return [
+ $group['customer_group_id'] => $data,
+ ];
+ });
+
+ $this->discount->customerGroups()->sync($cgAvailability);
+
+ $this->discount->channels()->sync($channels);
+
+ $this->discount->collections()->sync(
+ $this->selectedCollections
+ );
+ });
+
+ $this->emit('discount.saved', $this->discount->id);
+
+ $this->notify(
+ __('adminhub::notifications.discount.saved')
+ );
+
+ if ($redirect) {
+ redirect()->route('hub.discounts.show', $this->discount->id);
+ }
+ }
+
+ public function getSideMenuProperty()
+ {
+ return collect([
+ [
+ 'title' => __('adminhub::menu.product.basic-information'),
+ 'id' => 'basic-information',
+ 'has_errors' => $this->errorBag->hasAny([
+ 'discount.name',
+ 'discount.handle',
+ 'discount.starts_at',
+ 'discount.ends_at',
+ ]),
+ ],
+ [
+ 'title' => __('adminhub::menu.product.availability'),
+ 'id' => 'availability',
+ 'has_errors' => false,
+ ],
+ [
+ 'title' => 'Limitations',
+ 'id' => 'limitations',
+ 'has_errors' => false,
+ ],
+ [
+ 'title' => 'Conditions',
+ 'id' => 'conditions',
+ 'has_errors' => $this->errorBag->hasAny([
+ 'minPrices.*.price',
+ 'discount.max_uses',
+ ]),
+ ],
+ [
+ 'title' => 'Discount Type',
+ 'id' => 'type',
+ 'has_errors' => $this->errorBag->hasAny(array_merge(
+ $this->getDiscountComponent()->rules(),
+ ['selectedConditions', 'selectedRewards']
+ )),
+ ],
+ ]);
+ }
+
+ /**
+ * Render the livewire component.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view('adminhub::livewire.components.discounts.show')
+ ->layout('adminhub::layouts.app');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php
new file mode 100644
index 0000000000..a6ad074533
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountCreate.php
@@ -0,0 +1,85 @@
+discount = new Discount([
+ 'priority' => 1,
+ 'type' => DiscountTypesDiscount::class,
+ 'starts_at' => now()->startOfHour(),
+ 'data' => [],
+ ]);
+
+ $this->currency = Currency::getDefault();
+ $this->syncAvailability();
+ }
+
+ /**
+ * {@inheritDoc}.
+ */
+ public function rules()
+ {
+ $rules = array_merge([
+ 'discount.name' => 'required|unique:'.Discount::class.',name',
+ 'discount.handle' => 'required|unique:'.Discount::class.',handle',
+ 'discount.stop' => 'nullable',
+ 'discount.max_uses' => 'nullable|numeric|min:0',
+ 'discount.priority' => 'required|min:1',
+ 'discount.starts_at' => 'date',
+ 'discount.coupon' => 'nullable',
+ 'discount.ends_at' => 'nullable|date|after:starts_at',
+ 'discount.type' => 'string|required',
+ 'discount.data' => 'array',
+ 'selectedCollections' => 'array',
+ 'selectedBrands' => 'array',
+ ], $this->getDiscountComponent()->rules());
+
+ foreach ($this->currencies as $currency) {
+ $rules['discount.data.min_prices.'.$currency->code] = 'nullable';
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Handler for when the discount name is updated.
+ *
+ * @param string $val
+ * @return void
+ */
+ public function updatedDiscountName($val)
+ {
+ if (! $this->discount->handle) {
+ $this->discount->handle = Str::snake(strtolower($val));
+ }
+ }
+
+ /**
+ * Render the livewire component.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view('adminhub::livewire.components.discounts.create')
+ ->layout('adminhub::layouts.app');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php
new file mode 100644
index 0000000000..ccda811b6b
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountShow.php
@@ -0,0 +1,94 @@
+ 'required|unique:'.Discount::class.',name,'.$this->discount->id,
+ 'discount.handle' => 'required|unique:'.Discount::class.',handle,'.$this->discount->id,
+ 'discount.stop' => 'nullable',
+ 'discount.max_uses' => 'nullable|numeric|min:0',
+ 'discount.priority' => 'required|min:1',
+ 'discount.starts_at' => 'date',
+ 'discount.coupon' => 'nullable',
+ 'discount.ends_at' => 'nullable|date|after:starts_at',
+ 'discount.type' => 'string|required',
+ 'discount.data' => 'array',
+ 'selectedCollections' => 'array',
+ 'selectedBrands' => 'array',
+ ], $this->getDiscountComponent()->rules());
+
+ foreach ($this->currencies as $currency) {
+ $rules['discount.data.min_prices.'.$currency->code] = 'nullable';
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Computed property to determine whether the discount can be deleted.
+ *
+ * @return bool
+ */
+ public function getCanDeleteProperty()
+ {
+ return $this->deleteConfirm === $this->discount->name;
+ }
+
+ /**
+ * Delete the discount.
+ *
+ * @return Redirector
+ */
+ public function delete()
+ {
+ DB::transaction(function () {
+ $this->discount->purchasables()->delete();
+ $this->discount->purchasableConditions()->delete();
+ $this->discount->purchasableRewards()->delete();
+ $this->discount->collections()->delete();
+ $this->discount->delete();
+ });
+
+ $this->emit(
+ __('adminhub::notifications.discount.deleted')
+ );
+
+ return redirect()->route('hub.discounts.index');
+ }
+
+ /**
+ * Render the livewire component.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view('adminhub::livewire.components.discounts.show')
+ ->layout('adminhub::layouts.app');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountsIndex.php b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountsIndex.php
new file mode 100644
index 0000000000..3c5effb402
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountsIndex.php
@@ -0,0 +1,35 @@
+layout('adminhub::layouts.base');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/DiscountsTable.php b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountsTable.php
new file mode 100644
index 0000000000..881a7bd71c
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/DiscountsTable.php
@@ -0,0 +1,75 @@
+tableBuilder->baseColumns([
+ BadgeColumn::make('status', function ($record) {
+ $active = $record->starts_at?->isPast() && ! $record->ends_at?->isPast();
+ $expired = $record->ends_at?->isPast();
+ $future = $record->starts_at?->isFuture();
+
+ $status = $active ? 'active' : 'pending';
+
+ if ($expired) {
+ $status = 'expired';
+ }
+
+ if ($future) {
+ $status = 'scheduled';
+ }
+
+ return __('adminhub::components.discounts.index.status.'.$status);
+ })->states(function ($record) {
+ return [
+ 'info' => $record->starts_at?->isFuture(),
+ 'pending' => ! $record->starts_at?->isPast(),
+ 'success' => $record->starts_at?->isPast() && ! $record->ends_at?->isPast(),
+ 'danger' => $record->ends_at?->isPast(),
+ ];
+ }),
+ TextColumn::make('name')->heading(
+ __('adminhub::tables.headings.name')
+ )->url(function ($record) {
+ return route('hub.discounts.show', $record->id);
+ }),
+ TextColumn::make('type', function ($record) {
+ return (new $record->type)->getName();
+ }),
+ TextColumn::make('starts_at'),
+ TextColumn::make('ends_at'),
+ ]);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getData()
+ {
+ return Discount::paginate($this->perPage);
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/Types/AbstractDiscountType.php b/packages/admin/src/Http/Livewire/Components/Discounts/Types/AbstractDiscountType.php
new file mode 100644
index 0000000000..e6c6f990cc
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/Types/AbstractDiscountType.php
@@ -0,0 +1,58 @@
+ 'save',
+ 'product-search.selected' => 'selectProducts',
+ ];
+
+ public function getValidationMessages()
+ {
+ return [];
+ }
+
+ public function save()
+ {
+ // ..
+ }
+
+ public function selectProducts(array $ids, $ref = null)
+ {
+ // ..
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function parentComponentErrorBag($errorBag)
+ {
+ $this->setErrorBag($errorBag);
+ }
+
+ /**
+ * Handle when the discount data is updated.
+ *
+ * @return void
+ */
+ public function updatedDiscount()
+ {
+ $this->emitUp('discountData.updated', $this->discount->data);
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php b/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php
new file mode 100644
index 0000000000..63bcbc7735
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/Types/BuyXGetY.php
@@ -0,0 +1,236 @@
+ 'array',
+ 'discount.data.min_qty' => 'required',
+ 'discount.data.reward_qty' => 'required|numeric',
+ 'discount.data.max_reward_qty' => 'required|numeric',
+ 'selectedConditions' => 'array|min:1',
+ 'selectedRewards' => 'array|min:1',
+ ];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function mount()
+ {
+ parent::mount();
+
+ $this->conditions = $this->discount->purchasableConditions()
+ ->wherePurchasableType(Product::class)
+ ->pluck('purchasable_id')->values();
+
+ $this->rewards = $this->discount->purchasableRewards()
+ ->wherePurchasableType(Product::class)
+ ->pluck('purchasable_id')->values();
+ }
+
+ /**
+ * Return the purchasable condition models.
+ *
+ * @return Collection
+ */
+ public function getPurchasableConditionsProperty()
+ {
+ return Product::whereIn(
+ 'id',
+ $this->conditions
+ )->get();
+ }
+
+ /**
+ * Return the purchasable reward models.
+ *
+ * @return Collection
+ */
+ public function getPurchasableRewardsProperty()
+ {
+ return Product::whereIn(
+ 'id',
+ $this->rewards
+ )->get();
+ }
+
+ /**
+ * Handle when the discount data is updated.
+ *
+ * @return void
+ */
+ public function updatedDiscountDataMinQty()
+ {
+ $this->emitUp('discountData.updated', $this->discount->data);
+ }
+
+ /**
+ * Handle when the discount data is updated.
+ *
+ * @return void
+ */
+ public function updatedDiscountDataRewardQty()
+ {
+ $this->emitUp('discountData.updated', $this->discount->data);
+ }
+
+ /**
+ * Remove a condition based on the product id
+ *
+ * @param string|int $productId
+ * @return void
+ */
+ public function removeCondition($productId)
+ {
+ $index = $this->conditions->search($productId);
+
+ $conditions = $this->conditions;
+
+ $conditions->forget($index);
+
+ $this->conditions = $conditions;
+
+ $this->emit('discount.conditions', $conditions->toArray());
+ }
+
+ /**
+ * Remove a reward based on the product id
+ *
+ * @param string|int $productId
+ * @return void
+ */
+ public function removeReward($productId)
+ {
+ $index = $this->rewards->search($productId);
+
+ $rewards = $this->rewards;
+
+ $rewards->forget($index);
+
+ $this->rewards = $rewards;
+ }
+
+ /**
+ * Select products
+ *
+ * @param array $ids
+ * @param string|null $ref
+ * @return void
+ */
+ public function selectProducts(array $ids, $ref = null)
+ {
+ if ($ref == 'discount-conditions') {
+ $this->conditions = collect($ids);
+ $this->emit('discount.conditions', $this->conditions->toArray());
+ }
+
+ if ($ref == 'discount-rewards') {
+ $this->rewards = collect($ids);
+ $this->emit('discount.rewards', $this->rewards->toArray());
+ }
+ }
+
+ public function getValidationMessages()
+ {
+ return [
+ 'discount.data.min_qty.required' => 'This field is required',
+ 'discount.data.reward_qty.required' => 'This field is required',
+ 'discount.data.max_reward_qty.required' => 'This field is required',
+ ];
+ }
+
+ /**
+ * Save the product discount.
+ *
+ * @return void
+ */
+ public function save()
+ {
+ DB::transaction(function () {
+ $conditions = $this->conditions;
+
+ $this->discount->purchasableConditions()
+ ->whereNotIn('purchasable_id', $conditions)
+ ->delete();
+
+ foreach ($conditions as $condition) {
+ $this->discount->purchasables()->firstOrCreate([
+ 'discount_id' => $this->discount->id,
+ 'type' => 'condition',
+ 'purchasable_type' => Product::class,
+ 'purchasable_id' => $condition,
+ ]);
+ }
+
+ $rewards = $this->rewards;
+
+ $this->discount->purchasableConditions()
+ ->whereNotIn('purchasable_id', $conditions)
+ ->delete();
+
+ foreach ($rewards as $reward) {
+ $this->discount->purchasables()->firstOrCreate([
+ 'discount_id' => $this->discount->id,
+ 'type' => 'reward',
+ 'purchasable_type' => Product::class,
+ 'purchasable_id' => $reward,
+ ]);
+ }
+ });
+ }
+
+ /**
+ * Return the available currencies.
+ *
+ * @return Collection
+ */
+ public function getCurrenciesProperty()
+ {
+ return Currency::get();
+ }
+
+ /**
+ * Render the livewire component.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view('adminhub::livewire.components.discounts.types.buy-x-get-y')
+ ->layout('adminhub::layouts.base');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/Discounts/Types/Discount.php b/packages/admin/src/Http/Livewire/Components/Discounts/Types/Discount.php
new file mode 100644
index 0000000000..3ee0a8b669
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Components/Discounts/Types/Discount.php
@@ -0,0 +1,100 @@
+ 'array',
+ 'discount.data.percentage' => 'required_if:discount.data.fixed_value,false|nullable|numeric|min:1',
+ 'discount.data.fixed_values' => 'array|min:0',
+ 'discount.data.fixed_value' => 'boolean',
+ ];
+
+ foreach ($this->currencies as $currency) {
+ $rules["discount.data.fixed_values.{$currency->code}"] = 'required_if:discount.data.fixed_value,true|nullable|numeric|min:1';
+ }
+
+ return $rules;
+ }
+
+ public function getValidationMessages()
+ {
+ $messages = [
+ 'discount.data.percentage.required_if' => 'This field is required',
+ 'discount.data.percentage.min' => 'Percentage must be at least :min',
+ 'discount.data.max_reward_qty.required' => 'This field is required',
+ ];
+
+ foreach ($this->currencies as $currency) {
+ $messages["discount.data.fixed_values.{$currency->code}.required_if"] = 'This field is required';
+ }
+
+ return $messages;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function mount()
+ {
+ parent::mount();
+
+ if (empty($this->discount->data)) {
+ $this->discount->data = [
+ 'coupon' => null,
+ 'fixed_value' => false,
+ ];
+ }
+ }
+
+ /**
+ * Listen to when the coupon is updated and emit the data change.
+ *
+ * @param string $val
+ * @return void
+ */
+ public function updatedDiscountDataCoupon($val)
+ {
+ $data = (array) $this->discount->data;
+
+ $data['coupon'] = strtoupper(
+ Str::snake(
+ strtolower($val)
+ )
+ );
+
+ $this->discount->data = $data;
+ $this->emitUp('discountData.updated', $this->discount->data);
+ }
+
+ /**
+ * Return the available currencies.
+ *
+ * @return Collection
+ */
+ public function getCurrenciesProperty()
+ {
+ return Currency::get();
+ }
+
+ /**
+ * Render the livewire component.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view('adminhub::livewire.components.discounts.types.discount')
+ ->layout('adminhub::layouts.base');
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Components/ProductSearch.php b/packages/admin/src/Http/Livewire/Components/ProductSearch.php
index 546bfadf64..022a2f2a34 100644
--- a/packages/admin/src/Http/Livewire/Components/ProductSearch.php
+++ b/packages/admin/src/Http/Livewire/Components/ProductSearch.php
@@ -93,7 +93,7 @@ public function getResultsProperty()
public function triggerSelect()
{
- $this->emit('product-search.selected', $this->selected);
+ $this->emit('product-search.selected', $this->selected, $this->ref);
$this->showBrowser = false;
}
diff --git a/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountCreate.php b/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountCreate.php
new file mode 100644
index 0000000000..1d22b72952
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountCreate.php
@@ -0,0 +1,34 @@
+discount = new Discount;
+ }
+
+ /**
+ * Render the livewire component.
+ *
+ * @return \Illuminate\View\View
+ */
+ public function render()
+ {
+ return view('adminhub::livewire.pages.discounts.create')
+ ->layout('adminhub::layouts.app', [
+ 'title' => __('adminhub::components.discounts.create.title'),
+ ]);
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountShow.php b/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountShow.php
new file mode 100644
index 0000000000..3de0632014
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountShow.php
@@ -0,0 +1,29 @@
+layout('adminhub::layouts.app', [
+ 'title' => 'Discounts',
+ ]);
+ }
+}
diff --git a/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountsIndex.php b/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountsIndex.php
new file mode 100644
index 0000000000..1f47e61cb2
--- /dev/null
+++ b/packages/admin/src/Http/Livewire/Pages/Discounts/DiscountsIndex.php
@@ -0,0 +1,21 @@
+layout('adminhub::layouts.app', [
+ 'title' => 'Discounts',
+ ]);
+ }
+}
diff --git a/packages/admin/src/Menu/SidebarMenu.php b/packages/admin/src/Menu/SidebarMenu.php
index fa73a0882a..a265b79da5 100644
--- a/packages/admin/src/Menu/SidebarMenu.php
+++ b/packages/admin/src/Menu/SidebarMenu.php
@@ -67,6 +67,14 @@ protected function addSections()
->icon('pencil');
});
+ $slot->addItem(function ($item) {
+ $item->name(
+ __('adminhub::menu.sidebar.discounts')
+ )->handle('hub.discounts')
+ ->route('hub.discounts.index')
+ ->icon('ticket');
+ });
+
$slot->addItem(function ($item) {
$item->name(
__('adminhub::menu.sidebar.brands')
diff --git a/packages/admin/src/Views/Components/Input/Radio.php b/packages/admin/src/Views/Components/Input/Radio.php
new file mode 100644
index 0000000000..fe930c5139
--- /dev/null
+++ b/packages/admin/src/Views/Components/Input/Radio.php
@@ -0,0 +1,44 @@
+on = $on;
+ $this->disabled = $disabled;
+ }
+
+ /**
+ * Get the view / contents that represent the component.
+ *
+ * @return \Illuminate\View\View|\Closure|string
+ */
+ public function render()
+ {
+ return view('adminhub::components.input.radio');
+ }
+}
diff --git a/packages/core/config/cart.php b/packages/core/config/cart.php
index 6c470969f2..0eff94cd08 100644
--- a/packages/core/config/cart.php
+++ b/packages/core/config/cart.php
@@ -56,6 +56,7 @@
'cart' => [
\Lunar\Pipelines\Cart\CalculateLines::class,
\Lunar\Pipelines\Cart\ApplyShipping::class,
+ \Lunar\Pipelines\Cart\ApplyDiscounts::class,
\Lunar\Pipelines\Cart\Calculate::class,
],
/*
diff --git a/packages/core/config/discounts.php b/packages/core/config/discounts.php
new file mode 100644
index 0000000000..bcb3863b7f
--- /dev/null
+++ b/packages/core/config/discounts.php
@@ -0,0 +1,14 @@
+ \Lunar\Base\Validation\CouponValidator::class,
+];
diff --git a/packages/core/database/factories/DiscountFactory.php b/packages/core/database/factories/DiscountFactory.php
new file mode 100644
index 0000000000..bc444882f8
--- /dev/null
+++ b/packages/core/database/factories/DiscountFactory.php
@@ -0,0 +1,25 @@
+faker->unique()->name;
+
+ return [
+ 'name' => $name,
+ 'handle' => Str::snake($name),
+ 'type' => Coupon::class,
+ 'starts_at' => now(),
+ ];
+ }
+}
diff --git a/packages/core/database/factories/DiscountPurchasableFactory.php b/packages/core/database/factories/DiscountPurchasableFactory.php
new file mode 100644
index 0000000000..300b68601c
--- /dev/null
+++ b/packages/core/database/factories/DiscountPurchasableFactory.php
@@ -0,0 +1,20 @@
+ ProductVariant::factory(),
+ 'purchasable_type' => ProductVariant::class,
+ ];
+ }
+}
diff --git a/packages/core/database/migrations/2022_11_18_100000_create_discounts_table.php b/packages/core/database/migrations/2022_11_18_100000_create_discounts_table.php
new file mode 100644
index 0000000000..6fa3d3fb01
--- /dev/null
+++ b/packages/core/database/migrations/2022_11_18_100000_create_discounts_table.php
@@ -0,0 +1,33 @@
+prefix.'discounts', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('handle')->unique();
+ $table->string('coupon')->nullable()->unique();
+ $table->string('type')->index();
+ $table->dateTime('starts_at')->index();
+ $table->dateTime('ends_at')->nullable()->index();
+ $table->integer('uses')->unsigned()->default(0)->index();
+ $table->mediumInteger('max_uses')->unsigned()->nullable();
+ $table->mediumInteger('priority')->unsigned()->index()->default(1);
+ $table->boolean('stop')->default(false)->index();
+ $table->string('restriction')->index()->nullable();
+ $table->json('data')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists($this->prefix.'discounts');
+ }
+}
diff --git a/packages/core/database/migrations/2022_11_18_100005_create_cart_line_discount_table.php b/packages/core/database/migrations/2022_11_18_100005_create_cart_line_discount_table.php
new file mode 100644
index 0000000000..42b7ff5a5b
--- /dev/null
+++ b/packages/core/database/migrations/2022_11_18_100005_create_cart_line_discount_table.php
@@ -0,0 +1,23 @@
+prefix.'cart_line_discount', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('cart_line_id')->constrained($this->prefix.'carts')->cascadeOnDelete();
+ $table->foreignId('discount_id')->constrained($this->prefix.'discounts')->cascadeOnDelete();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists($this->prefix.'cart_line_discount');
+ }
+}
diff --git a/packages/core/database/migrations/2022_11_18_100010_create_brand_discount_table.php b/packages/core/database/migrations/2022_11_18_100010_create_brand_discount_table.php
new file mode 100644
index 0000000000..02ecfdd56d
--- /dev/null
+++ b/packages/core/database/migrations/2022_11_18_100010_create_brand_discount_table.php
@@ -0,0 +1,23 @@
+prefix.'brand_discount', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('brand_id')->constrained($this->prefix.'brands')->cascadeOnDelete();
+ $table->foreignId('discount_id')->constrained($this->prefix.'discounts')->cascadeOnDelete();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists($this->prefix.'brand_discount');
+ }
+}
diff --git a/packages/core/database/migrations/2022_11_18_100015_create_customer_group_discount_table.php b/packages/core/database/migrations/2022_11_18_100015_create_customer_group_discount_table.php
new file mode 100644
index 0000000000..eae908e447
--- /dev/null
+++ b/packages/core/database/migrations/2022_11_18_100015_create_customer_group_discount_table.php
@@ -0,0 +1,25 @@
+prefix.'customer_group_discount', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('discount_id')->constrained($this->prefix.'discounts');
+ $table->foreignId('customer_group_id')->constrained($this->prefix.'customer_groups');
+ $table->scheduling();
+ $table->boolean('visible')->default(true)->index();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists($this->prefix.'customer_group_discount');
+ }
+}
diff --git a/packages/core/database/migrations/2022_11_18_100020_create_discount_collections_table.php b/packages/core/database/migrations/2022_11_18_100020_create_discount_collections_table.php
new file mode 100644
index 0000000000..e4c606a6af
--- /dev/null
+++ b/packages/core/database/migrations/2022_11_18_100020_create_discount_collections_table.php
@@ -0,0 +1,23 @@
+prefix.'collection_discount', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('discount_id')->constrained($this->prefix.'discounts')->cascadeOnDelete();
+ $table->foreignId('collection_id')->constrained($this->prefix.'collections')->cascadeOnDelete();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists($this->prefix.'collection_discount');
+ }
+}
diff --git a/packages/core/database/migrations/2022_11_18_100030_create_discount_purchasables_table.php b/packages/core/database/migrations/2022_11_18_100030_create_discount_purchasables_table.php
new file mode 100644
index 0000000000..9cb06e6893
--- /dev/null
+++ b/packages/core/database/migrations/2022_11_18_100030_create_discount_purchasables_table.php
@@ -0,0 +1,24 @@
+prefix.'discount_purchasables', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('discount_id')->constrained($this->prefix.'discounts')->cascadeOnDelete();
+ $table->morphs('purchasable', 'purchasable_idx');
+ $table->string('type')->default('condition')->index();
+ $table->timestamps();
+ });
+ }
+
+ public function down()
+ {
+ Schema::dropIfExists($this->prefix.'discount_purchasables');
+ }
+}
diff --git a/packages/core/resources/lang/en/exceptions.php b/packages/core/resources/lang/en/exceptions.php
index 05a382a88c..b0fbf561ad 100644
--- a/packages/core/resources/lang/en/exceptions.php
+++ b/packages/core/resources/lang/en/exceptions.php
@@ -11,4 +11,5 @@
'missing_currency_price' => 'No price for currency ":currency" exists',
'fieldtype_missing' => 'FieldType ":class" does not exist',
'invalid_fieldtype' => 'Class ":class" does not implement the FieldType interface.',
+ 'discounts.invalid_type' => 'Collection must only contain ":expected", found ":actual"',
];
diff --git a/packages/core/src/Actions/Carts/CalculateLine.php b/packages/core/src/Actions/Carts/CalculateLine.php
index 36bafeec27..a3f8d9eb0a 100644
--- a/packages/core/src/Actions/Carts/CalculateLine.php
+++ b/packages/core/src/Actions/Carts/CalculateLine.php
@@ -5,7 +5,6 @@
use Illuminate\Support\Collection;
use Lunar\Base\Addressable;
use Lunar\DataTypes\Price;
-use Lunar\Facades\Pricing;
use Lunar\Facades\Taxes;
use Lunar\Models\CartLine;
@@ -28,28 +27,13 @@ public function execute(
$cart = $cartLine->cart;
$unitQuantity = $purchasable->getUnitQuantity();
- // we check if any cart line modifiers have already specified a unit price in their calculating() method
- if (! ($price = $cartLine->unitPrice) instanceof Price) {
- $priceResponse = Pricing::currency($cart->currency)
- ->qty($cartLine->quantity)
- ->currency($cart->currency)
- ->customerGroups($customerGroups)
- ->for($purchasable)
- ->get();
+ $cartLine = app(CalculateLineSubtotal::class)->execute($cartLine, $customerGroups);
- $price = new Price(
- $priceResponse->matched->price->value,
- $cart->currency,
- $purchasable->getUnitQuantity()
- );
+ if (! $cartLine->discountTotal) {
+ $cartLine->discountTotal = new Price(0, $cart->currency, $unitQuantity);
}
- $unitPrice = (int) round(
- (($price->decimal / $purchasable->getUnitQuantity())
- * $cart->currency->factor),
- $cart->currency->decimal_places);
-
- $subTotal = $unitPrice * $cartLine->quantity;
+ $subTotal = $cartLine->subTotal->value - $cartLine->discountTotal->value;
$taxBreakDown = Taxes::setShippingAddress($shippingAddress)
->setBillingAddress($billingAddress)
@@ -61,11 +45,8 @@ public function execute(
$taxTotal = $taxBreakDown->amounts->sum('price.value');
$cartLine->taxBreakdown = $taxBreakDown;
- $cartLine->subTotal = new Price($subTotal, $cart->currency, $unitQuantity);
$cartLine->taxAmount = new Price($taxTotal, $cart->currency, $unitQuantity);
$cartLine->total = new Price($subTotal + $taxTotal, $cart->currency, $unitQuantity);
- $cartLine->unitPrice = new Price($unitPrice, $cart->currency, $unitQuantity);
- $cartLine->discountTotal = new Price(0, $cart->currency, $unitQuantity);
return $cartLine;
}
diff --git a/packages/core/src/Actions/Carts/CalculateLineSubtotal.php b/packages/core/src/Actions/Carts/CalculateLineSubtotal.php
new file mode 100644
index 0000000000..0942c874b6
--- /dev/null
+++ b/packages/core/src/Actions/Carts/CalculateLineSubtotal.php
@@ -0,0 +1,71 @@
+purchasable;
+ $cart = $cartLine->cart;
+ $unitQuantity = $purchasable->getUnitQuantity();
+
+ // we check if any cart line modifiers have already specified a unit price in their calculating() method
+ if (! ($price = $cartLine->unitPrice) instanceof Price) {
+ $priceResponse = Pricing::currency($cart->currency)
+ ->qty($cartLine->quantity)
+ ->currency($cart->currency)
+ ->customerGroups($customerGroups)
+ ->for($purchasable)
+ ->get();
+
+ $price = new Price(
+ $priceResponse->matched->price->value,
+ $cart->currency,
+ $purchasable->getUnitQuantity()
+ );
+ }
+
+ $unitPrice = (int) round(
+ (($price->decimal / $purchasable->getUnitQuantity())
+ * $cart->currency->factor),
+ $cart->currency->decimal_places
+ );
+
+ $cartLine->subTotal = new Price($unitPrice * $cartLine->quantity, $cart->currency, $unitQuantity);
+ $cartLine->unitPrice = new Price($unitPrice, $cart->currency, $unitQuantity);
+
+ $pipeline = app(Pipeline::class)
+ ->through(
+ $this->getModifiers()->toArray()
+ );
+
+ return $pipeline->send($cartLine)->via('subtotalled')->thenReturn();
+ }
+
+ /**
+ * Return the cart line modifiers.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ private function getModifiers()
+ {
+ return app(CartLineModifiers::class)->getModifiers();
+ }
+}
diff --git a/packages/core/src/Actions/Carts/CreateOrder.php b/packages/core/src/Actions/Carts/CreateOrder.php
index ab7059bab6..09cec4f709 100644
--- a/packages/core/src/Actions/Carts/CreateOrder.php
+++ b/packages/core/src/Actions/Carts/CreateOrder.php
@@ -45,6 +45,7 @@ public function execute(
'currency_code' => $cart->currency->code,
'exchange_rate' => $cart->currency->exchange_rate,
'compare_currency_code' => Currency::getDefault()?->code,
+ 'meta' => $cart->meta,
]);
$order->update([
diff --git a/packages/core/src/Base/CartLineModifier.php b/packages/core/src/Base/CartLineModifier.php
index 0d2a323ef1..d36e181e9a 100644
--- a/packages/core/src/Base/CartLineModifier.php
+++ b/packages/core/src/Base/CartLineModifier.php
@@ -26,4 +26,14 @@ public function calculated(CartLine $cartLine, Closure $next): CartLine
{
return $next($cartLine);
}
+
+ /**
+ * Called just after cart sub total is calculated.
+ *
+ * @return CartLine
+ */
+ public function subtotalled(CartLine $cartLine, Closure $next): CartLine
+ {
+ return $next($cartLine);
+ }
}
diff --git a/packages/core/src/Base/DataTransferObjects/CartDiscount.php b/packages/core/src/Base/DataTransferObjects/CartDiscount.php
new file mode 100644
index 0000000000..5c6725b715
--- /dev/null
+++ b/packages/core/src/Base/DataTransferObjects/CartDiscount.php
@@ -0,0 +1,17 @@
+active()
+ ->where(function ($query) {
+ $query->whereNull('max_uses')
+ ->orWhereRaw('uses < max_uses');
+ })->where('coupon', '=', strtoupper($coupon))->exists();
+ }
+}
diff --git a/packages/core/src/Base/Validation/CouponValidatorInterface.php b/packages/core/src/Base/Validation/CouponValidatorInterface.php
new file mode 100644
index 0000000000..8b657e5383
--- /dev/null
+++ b/packages/core/src/Base/Validation/CouponValidatorInterface.php
@@ -0,0 +1,14 @@
+discount = $discount;
+
+ return $this;
+ }
+}
diff --git a/packages/core/src/DiscountTypes/BuyXGetY.php b/packages/core/src/DiscountTypes/BuyXGetY.php
new file mode 100644
index 0000000000..a8763c2ecc
--- /dev/null
+++ b/packages/core/src/DiscountTypes/BuyXGetY.php
@@ -0,0 +1,131 @@
+discount = $discount;
+
+ return $this;
+ }
+
+ /**
+ * Return the name of the discount.
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'Buy X Get Y';
+ }
+
+ /**
+ * Return the reward quantity for the discount
+ *
+ * @param int $linesQuantity
+ * @param int $minQty
+ * @param int $rewardQty
+ * @param int $maxRewardQty
+ * @return int
+ */
+ public function getRewardQuantity($linesQuantity, $minQty, $rewardQty, $maxRewardQty = null)
+ {
+ $result = ($linesQuantity / $minQty) * $rewardQty;
+
+ if ($maxRewardQty && $result > $maxRewardQty) {
+ return $maxRewardQty;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Called just before cart totals are calculated.
+ *
+ * @return CartLine
+ */
+ public function apply(Cart $cart): Cart
+ {
+ $data = $this->discount->data;
+
+ $minQty = $data['min_qty'] ?? null;
+ $rewardQty = $data['reward_qty'] ?? 1;
+ $maxRewardQty = $data['max_reward_qty'] ?? null;
+
+ // Get the first condition line where the qty check passes.
+ $conditions = $cart->lines->reject(function ($line) use ($minQty) {
+ $match = $this->discount->purchasableConditions->first(function ($item) use ($line) {
+ return $item->purchasable_type == Product::class &&
+ $item->purchasable_id == $line->purchasable->product->id;
+ });
+
+ return ! $match || ($minQty && $line->quantity < $minQty);
+ });
+
+ if (! $conditions->count()) {
+ return $cart;
+ }
+
+ // How many products are rewarded?
+ $totalRewardQty = $this->getRewardQuantity(
+ $conditions->sum('quantity'),
+ $minQty,
+ $rewardQty,
+ $maxRewardQty
+ );
+
+ $remainingRewardQty = $totalRewardQty;
+
+ // Get the reward lines and sort by cheapest first.
+ $rewardLines = $cart->lines->filter(function ($line) {
+ return $this->discount->purchasableRewards->first(function ($item) use ($line) {
+ return $item->purchasable_type == Product::class &&
+ $item->purchasable_id == $line->purchasable->product->id;
+ });
+ })->sortBy('subTotal.value');
+
+ foreach ($rewardLines as $rewardLine) {
+ if (! $remainingRewardQty) {
+ continue;
+ }
+
+ $remainder = $rewardLine->quantity % $remainingRewardQty;
+
+ $qtyToAllocate = ($remainingRewardQty - $remainder) / $rewardLine->quantity;
+
+ $remainingRewardQty -= $qtyToAllocate;
+
+ $subTotal = $rewardLine->subTotal->value;
+
+ $rewardLine->discountTotal = new Price(
+ $subTotal * $qtyToAllocate,
+ $cart->currency,
+ 1
+ );
+
+ if (! $cart->freeItems) {
+ $cart->freeItems = collect();
+ }
+
+ $cart->freeItems->push($rewardLine->purchasable);
+ }
+
+ return $cart;
+ }
+}
diff --git a/packages/core/src/DiscountTypes/Discount.php b/packages/core/src/DiscountTypes/Discount.php
new file mode 100644
index 0000000000..112f7644c2
--- /dev/null
+++ b/packages/core/src/DiscountTypes/Discount.php
@@ -0,0 +1,151 @@
+discount->data;
+
+ $cartCoupon = strtoupper($cart->coupon_code ?? null);
+ $conditionCoupon = strtoupper($this->discount->coupon ?? null);
+
+ $passes = $cartCoupon && ($cartCoupon === $conditionCoupon);
+
+ $minSpend = $data['min_prices'][$cart->currency->code] ?? null;
+
+ $lines = $this->getEligibleLines($cart);
+
+ if (! $passes || ($minSpend && $minSpend < $lines->sum('subTotal.value'))) {
+ return $cart;
+ }
+
+ if ($data['fixed_value']) {
+ return $this->applyFixedValue(
+ values: $data['fixed_values'],
+ cart: $cart,
+ );
+ }
+
+ return $this->applyPercentage(
+ value: $data['percentage'],
+ cart: $cart
+ );
+ }
+
+ /**
+ * Apply fixed value discount
+ *
+ * @param array $values
+ * @param Cart $cart
+ * @return Cart
+ */
+ private function applyFixedValue(array $values, Cart $cart): Cart
+ {
+ $currency = $cart->currency;
+
+ $value = ($values[$currency->code] ?? 0) * 100;
+
+ $lines = $this->getEligibleLines($cart);
+
+ if (! $value || $lines->sum('subTotal.value') < $value) {
+ return $cart;
+ }
+
+ $cart->cartDiscountAmount = new Price(
+ $value,
+ $currency,
+ 1
+ );
+
+ if (! $cart->discounts) {
+ $cart->discounts = collect();
+ }
+
+ $cart->discounts->push($this);
+
+ return $cart;
+ }
+
+ /**
+ * Return the eligible lines for the discount.
+ *
+ * @param Cart $cart
+ * @return Collection
+ */
+ private function getEligibleLines(Cart $cart)
+ {
+ $collectionIds = $this->discount->collections->pluck('id');
+ $brandIds = $this->discount->brands->pluck('id');
+
+ $lines = $cart->lines;
+
+ if ($collectionIds->count()) {
+ $lines = $lines->filter(function ($line) use ($collectionIds) {
+ return $line->purchasable->product()->whereHas('collections', function ($query) use ($collectionIds) {
+ $query->whereIn((new Collection)->getTable().'.id', $collectionIds);
+ })->exists();
+ });
+ }
+
+ if ($brandIds->count()) {
+ $lines = $lines->reject(function ($line) use ($brandIds) {
+ return ! $brandIds->contains($line->purchasable->product->brand_id);
+ });
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Apply the percentage to the cart line.
+ *
+ * @param int $value
+ * @param CartLine $cartLine
+ * @return CartLine
+ */
+ private function applyPercentage($value, $cart): Cart
+ {
+ $lines = $this->getEligibleLines($cart);
+
+ foreach ($lines as $line) {
+ $subTotal = $line->subTotal->value;
+ $amount = (int) round($subTotal * ($value / 100));
+
+ $line->discountTotal = new Price(
+ $amount,
+ $cart->currency,
+ 1
+ );
+ }
+
+ if (! $cart->discounts) {
+ $cart->discounts = collect();
+ }
+
+ $cart->discounts->push($this);
+
+ return $cart;
+ }
+}
diff --git a/packages/core/src/Facades/Discounts.php b/packages/core/src/Facades/Discounts.php
new file mode 100644
index 0000000000..7fc4458b01
--- /dev/null
+++ b/packages/core/src/Facades/Discounts.php
@@ -0,0 +1,17 @@
+app->singleton(PaymentManagerInterface::class, function ($app) {
return $app->make(PaymentManager::class);
});
+
+ $this->app->singleton(DiscountManagerInterface::class, function ($app) {
+ return $app->make(DiscountManager::class);
+ });
}
/**
diff --git a/packages/core/src/Managers/DiscountManager.php b/packages/core/src/Managers/DiscountManager.php
new file mode 100644
index 0000000000..4043bbf46b
--- /dev/null
+++ b/packages/core/src/Managers/DiscountManager.php
@@ -0,0 +1,223 @@
+
+ */
+ protected ?Collection $channels = null;
+
+ /**
+ * The current customer groups
+ *
+ * @var null|Collection
+ */
+ protected ?Collection $customerGroups = null;
+
+ /**
+ * The available discounts
+ *
+ * @var null|Collection
+ */
+ protected ?Collection $discounts = null;
+
+ /**
+ * The available discount types
+ *
+ * @var array
+ */
+ protected $types = [
+ TypesDiscount::class,
+ BuyXGetY::class,
+ ];
+
+ /**
+ * The applied discounts.
+ *
+ * @var Collection
+ */
+ protected Collection $applied;
+
+ /**
+ * Instantiate the class.
+ */
+ public function __construct()
+ {
+ $this->applied = collect();
+ $this->channels = collect();
+ $this->customerGroups = collect();
+ }
+
+ /**
+ * Set a single channel or a collection.
+ *
+ * @param Channel|iterable $channel
+ * @return self
+ */
+ public function channel(Channel|iterable $channel): self
+ {
+ $channels = collect(
+ ! is_iterable($channel) ? [$channel] : $channel
+ );
+
+ if ($nonChannel = $channels->filter(fn ($channel) => ! $channel instanceof Channel)->first()) {
+ throw new InvalidArgumentException(
+ __('lunar::exceptions.discounts.invalid_type', [
+ 'expected' => Channel::class,
+ 'actual' => get_class($nonChannel),
+
+ ])
+ );
+ }
+
+ $this->channels = $channels;
+
+ return $this;
+ }
+
+ /**
+ * Set a single customer group or a collection.
+ *
+ * @param CustomerGroup|iterable $customerGroups
+ * @return self
+ */
+ public function customerGroup(CustomerGroup|iterable $customerGroups): self
+ {
+ $customerGroups = collect(
+ ! is_iterable($customerGroups) ? [$customerGroups] : $customerGroups
+ );
+
+ if ($nonGroup = $customerGroups->filter(fn ($channel) => ! $channel instanceof CustomerGroup)->first()) {
+ throw new InvalidArgumentException(
+ __('lunar::exceptions.discounts.invalid_type', [
+ 'expected' => CustomerGroup::class,
+ 'actual' => get_class($nonGroup),
+ ])
+ );
+ }
+ $this->customerGroups = $customerGroups;
+
+ return $this;
+ }
+
+ /**
+ * Return the applied channels.
+ *
+ * @return Collection
+ */
+ public function getChannels(): Collection
+ {
+ return $this->channels;
+ }
+
+ /**
+ * Returns the available discounts.
+ *
+ * @return Collection
+ */
+ public function getDiscounts(): Collection
+ {
+ if ($this->channels->isEmpty() && $defaultChannel = Channel::getDefault()) {
+ $this->channel($defaultChannel);
+ }
+
+ if ($this->customerGroups->isEmpty() && $defaultGroup = CustomerGroup::getDefault()) {
+ $this->customerGroup($defaultGroup);
+ }
+
+ return Discount::active()->whereHas('channels', function ($query) {
+ $joinTable = (new Discount)->channels()->getTable();
+ $query->whereIn("{$joinTable}.channel_id", $this->channels->pluck('id'))
+ ->where("{$joinTable}.enabled", true)
+ ->whereNotNull("{$joinTable}.starts_at")
+ ->where("{$joinTable}.starts_at", '<=', now())
+ ->where(function ($query) use ($joinTable) {
+ $query->whereNull("{$joinTable}.ends_at")
+ ->orWhereDate("{$joinTable}.ends_at", '>', now());
+ });
+ })->whereHas('customerGroups', function ($query) {
+ $joinTable = (new Discount)->customerGroups()->getTable();
+
+ $query->whereIn("{$joinTable}.customer_group_id", $this->customerGroups->pluck('id'))
+ ->where("{$joinTable}.enabled", true)
+ ->whereNotNull("{$joinTable}.starts_at")
+ ->where("{$joinTable}.starts_at", '<=', now())
+ ->where(function ($query) use ($joinTable) {
+ $query->whereNull("{$joinTable}.ends_at")
+ ->orWhereDate("{$joinTable}.ends_at", '>', now());
+ });
+ })->orderBy('priority')->get();
+ }
+
+ /**
+ * Return the applied customer groups.
+ *
+ * @return Collection
+ */
+ public function getCustomerGroups(): Collection
+ {
+ return $this->customerGroups;
+ }
+
+ public function addType($classname): self
+ {
+ $this->types[] = $classname;
+
+ return $this;
+ }
+
+ public function getTypes(): Collection
+ {
+ return collect($this->types)->map(function ($class) {
+ return app($class);
+ });
+ }
+
+ public function addApplied(CartDiscount $cartDiscount): self
+ {
+ $this->applied->push($cartDiscount);
+
+ return $this;
+ }
+
+ public function getApplied(): Collection
+ {
+ return $this->applied;
+ }
+
+ public function apply(Cart $cart): Cart
+ {
+ if (! $this->discounts) {
+ $this->discounts = $this->getDiscounts();
+ }
+
+ foreach ($this->discounts as $discount) {
+ $cart = $discount->getType()->apply($cart);
+ }
+
+ return $cart;
+ }
+
+ public function validateCoupon(string $coupon): bool
+ {
+ return app(
+ config('lunar.discounts.coupon_validator', CouponValidator::class)
+ )->validate($coupon);
+ }
+}
diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php
index bfff75afbd..db4553dde4 100644
--- a/packages/core/src/Models/Cart.php
+++ b/packages/core/src/Models/Cart.php
@@ -17,6 +17,7 @@
use Lunar\Actions\Carts\UpdateCartLine;
use Lunar\Base\Addressable;
use Lunar\Base\BaseModel;
+use Lunar\Base\Casts\Address;
use Lunar\Base\Purchasable;
use Lunar\Base\Traits\CachesProperties;
use Lunar\Base\Traits\HasMacros;
diff --git a/packages/core/src/Models/CartLine.php b/packages/core/src/Models/CartLine.php
index 2864b595f9..1e9216375f 100644
--- a/packages/core/src/Models/CartLine.php
+++ b/packages/core/src/Models/CartLine.php
@@ -135,6 +135,16 @@ public function taxClass()
);
}
+ public function discounts()
+ {
+ $prefix = config('lunar.database.table_prefix');
+
+ return $this->belongsToMany(
+ Discount::class,
+ "{$prefix}cart_line_discount"
+ );
+ }
+
/**
* Return the polymorphic relation.
*
diff --git a/packages/core/src/Models/Collection.php b/packages/core/src/Models/Collection.php
index efeb8e55c3..81841bd10b 100644
--- a/packages/core/src/Models/Collection.php
+++ b/packages/core/src/Models/Collection.php
@@ -2,6 +2,7 @@
namespace Lunar\Models;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Arr;
@@ -80,6 +81,11 @@ public function group()
return $this->belongsTo(CollectionGroup::class, 'collection_group_id');
}
+ public function scopeInGroup(Builder $builder, $id)
+ {
+ return $builder->where('collection_group_id', $id);
+ }
+
/**
* Return the products relationship.
*
diff --git a/packages/core/src/Models/Discount.php b/packages/core/src/Models/Discount.php
new file mode 100644
index 0000000000..23fddc3f77
--- /dev/null
+++ b/packages/core/src/Models/Discount.php
@@ -0,0 +1,130 @@
+ 'datetime',
+ 'ends_at' => 'datetime',
+ 'data' => 'array',
+ ];
+
+ /**
+ * Return a new factory instance for the model.
+ *
+ * @return DiscountFactory
+ */
+ protected static function newFactory(): DiscountFactory
+ {
+ return DiscountFactory::new();
+ }
+
+ /**
+ * Return the purchasables relationship.
+ *
+ * @return HasMany
+ */
+ public function purchasables()
+ {
+ return $this->hasMany(DiscountPurchasable::class);
+ }
+
+ public function purchasableConditions()
+ {
+ return $this->hasMany(DiscountPurchasable::class)->whereType('condition');
+ }
+
+ public function purchasableRewards()
+ {
+ return $this->hasMany(DiscountPurchasable::class)->whereType('reward');
+ }
+
+ public function getType()
+ {
+ return app($this->type)->with($this);
+ }
+
+ /**
+ * Return the collections relationship.
+ *
+ * @return HasMany
+ */
+ public function collections()
+ {
+ $prefix = config('lunar.database.table_prefix');
+
+ return $this->belongsToMany(
+ Collection::class,
+ "{$prefix}collection_discount"
+ )->withTimestamps();
+ }
+
+ /**
+ * Return the customer groups relationship.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+ */
+ public function customerGroups(): BelongsToMany
+ {
+ $prefix = config('lunar.database.table_prefix');
+
+ return $this->belongsToMany(
+ CustomerGroup::class,
+ "{$prefix}customer_group_discount"
+ )->withPivot([
+ 'visible',
+ 'enabled',
+ 'starts_at',
+ 'ends_at',
+ ])->withTimestamps();
+ }
+
+ public function brands()
+ {
+ $prefix = config('lunar.database.table_prefix');
+
+ return $this->belongsToMany(
+ Brand::class,
+ "{$prefix}brand_discount"
+ )->withTimestamps();
+ }
+
+ /**
+ * Return the active scope.
+ *
+ * @param Builder $query
+ * @return void
+ */
+ public function scopeActive(Builder $query)
+ {
+ return $query->whereNotNull('starts_at')
+ ->where('starts_at', '<=', now())
+ ->where(function ($query) {
+ $query->whereNull('ends_at')
+ ->orWhere('ends_at', '>', now());
+ });
+ }
+}
diff --git a/packages/core/src/Models/DiscountCollection.php b/packages/core/src/Models/DiscountCollection.php
new file mode 100644
index 0000000000..07c37170ae
--- /dev/null
+++ b/packages/core/src/Models/DiscountCollection.php
@@ -0,0 +1,51 @@
+belongsTo(Discount::class);
+ }
+
+ public function collection()
+ {
+ return $this->belongsTo(Collection::class);
+ }
+}
diff --git a/packages/core/src/Models/DiscountPurchasable.php b/packages/core/src/Models/DiscountPurchasable.php
new file mode 100644
index 0000000000..3302a008f3
--- /dev/null
+++ b/packages/core/src/Models/DiscountPurchasable.php
@@ -0,0 +1,62 @@
+belongsTo(Discount::class);
+ }
+
+ /**
+ * Return the priceable relationship.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+ */
+ public function purchasable()
+ {
+ return $this->morphTo();
+ }
+
+ public function scopeCondition(Builder $query)
+ {
+ $query->whereType('condition');
+ }
+}
diff --git a/packages/core/src/Pipelines/Cart/ApplyDiscounts.php b/packages/core/src/Pipelines/Cart/ApplyDiscounts.php
new file mode 100644
index 0000000000..fcbebd04f6
--- /dev/null
+++ b/packages/core/src/Pipelines/Cart/ApplyDiscounts.php
@@ -0,0 +1,22 @@
+lines->sum('subTotal.value');
$discountTotal = $cart->lines->sum('discountTotal.value') + $cart->cartDiscountAmount?->value;
+
$taxTotal = $cart->lines->sum('taxAmount.value');
- $total = $cart->lines->sum('total.value');
+ $total = $cart->lines->sum('total.value') - $discountTotal;
+
$taxBreakDownAmounts = $cart->lines->pluck('taxBreakdown')->pluck('amounts')->flatten();
// Get the shipping address
diff --git a/packages/core/tests/Stubs/TestDiscountType.php b/packages/core/tests/Stubs/TestDiscountType.php
new file mode 100644
index 0000000000..e8aaad6a14
--- /dev/null
+++ b/packages/core/tests/Stubs/TestDiscountType.php
@@ -0,0 +1,30 @@
+create([
+ 'default' => true,
+ ]);
+
$billing = CartAddress::factory()->make([
'type' => 'billing',
'country_id' => Country::factory(),
@@ -187,6 +192,10 @@ public function cannot_create_order_with_incomplete_billing_address()
/** @test */
public function can_set_tax_breakdown_correctly()
{
+ CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
$billing = CartAddress::factory()->make([
'type' => 'billing',
'country_id' => Country::factory(),
diff --git a/packages/core/tests/Unit/Base/Validation/CouponValidatorTest.php b/packages/core/tests/Unit/Base/Validation/CouponValidatorTest.php
new file mode 100644
index 0000000000..5937f6ae5f
--- /dev/null
+++ b/packages/core/tests/Unit/Base/Validation/CouponValidatorTest.php
@@ -0,0 +1,129 @@
+create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'coupon' => '10OFF',
+ 'data' => [
+ 'fixed_value' => false,
+ 'percentage' => 10,
+ ],
+ ]);
+
+ $this->assertTrue(
+ $validator->validate('10OFF')
+ );
+
+ $this->assertTrue(
+ $validator->validate('10off')
+ );
+
+ $this->assertTrue(
+ $validator->validate('10oFf')
+ );
+
+ $this->assertFalse(
+ $validator->validate('20OFF')
+ );
+ }
+
+ /** @test **/
+ public function can_validate_based_on_uses()
+ {
+ $validator = app(CouponValidator::class);
+
+ $discount = Discount::factory()->create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'uses' => 10,
+ 'max_uses' => 20,
+ 'coupon' => '10OFF',
+ 'data' => [
+ 'fixed_value' => false,
+ 'percentage' => 10,
+ ],
+ ]);
+
+ $this->assertTrue(
+ $validator->validate('10OFF')
+ );
+
+ $discount->update([
+ 'uses' => 20,
+ ]);
+
+ $this->assertFalse(
+ $validator->validate('10OFF')
+ );
+
+ $discount->update([
+ 'max_uses' => null,
+ ]);
+
+ $this->assertTrue(
+ $validator->validate('10OFF')
+ );
+ }
+
+ /** @test */
+ public function can_validate_based_on_start_and_end_dates()
+ {
+ $validator = app(CouponValidator::class);
+
+ $discount = Discount::factory()->create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'uses' => 0,
+ 'max_uses' => null,
+ 'starts_at' => now()->startOfDay(),
+ 'ends_at' => now()->endOfWeek(),
+ 'coupon' => '10OFF',
+ 'data' => [
+ 'fixed_value' => false,
+ 'percentage' => 10,
+ ],
+ ]);
+
+ $this->assertTrue(
+ $validator->validate('10OFF')
+ );
+
+ $discount->update([
+ 'starts_at' => now()->subWeek(),
+ 'ends_at' => now()->subWeek()->endOfWeek(),
+ ]);
+
+ $this->assertFalse(
+ $validator->validate('10OFF')
+ );
+
+ $discount->update([
+ 'starts_at' => now()->subWeek(),
+ 'ends_at' => now()->subWeek()->endOfWeek(),
+ ]);
+
+ $this->assertFalse(
+ $validator->validate('10OFF')
+ );
+ }
+}
diff --git a/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php b/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php
new file mode 100644
index 0000000000..8a576f6d15
--- /dev/null
+++ b/packages/core/tests/Unit/DiscountTypes/BuyXGetYTest.php
@@ -0,0 +1,286 @@
+ 1,
+ 'minQty' => 1,
+ 'rewardQty' => 1,
+ 'expected' => 1,
+ ],
+ [
+ 'linesQuantity' => 2,
+ 'minQty' => 1,
+ 'rewardQty' => 1,
+ 'expected' => 2,
+ ],
+ [
+ 'linesQuantity' => 2,
+ 'minQty' => 2,
+ 'rewardQty' => 1,
+ 'expected' => 1,
+ ],
+ [
+ 'linesQuantity' => 10,
+ 'minQty' => 10,
+ 'rewardQty' => 1,
+ 'expected' => 1,
+ ],
+ [
+ 'linesQuantity' => 10,
+ 'minQty' => 1,
+ 'rewardQty' => 1,
+ 'expected' => 10,
+ ],
+ [
+ 'linesQuantity' => 10,
+ 'minQty' => 1,
+ 'rewardQty' => 1,
+ 'maxRewardQty' => 5,
+ 'expected' => 5,
+ ],
+ ];
+
+ foreach ($checks as $check) {
+ $this->assertEquals(
+ $check['expected'],
+ $driver->getRewardQuantity(
+ $check['linesQuantity'],
+ $check['minQty'],
+ $check['rewardQty'],
+ $check['maxRewardQty'] ?? null
+ )
+ );
+ }
+ }
+
+ /** @test */
+ public function can_discount_eligible_product()
+ {
+ $customerGroup = CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $currency = Currency::factory()->create([
+ 'code' => 'GBP',
+ ]);
+
+ $cart = Cart::factory()->create([
+ 'channel_id' => $channel->id,
+ 'currency_id' => $currency->id,
+ ]);
+
+ $productA = Product::factory()->create();
+
+ $productB = Product::factory()->create();
+
+ $purchasableA = ProductVariant::factory()->create([
+ 'product_id' => $productA->id,
+ ]);
+ $purchasableB = ProductVariant::factory()->create([
+ 'product_id' => $productB->id,
+ ]);
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableA),
+ 'priceable_id' => $purchasableA->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableA),
+ 'purchasable_id' => $purchasableA->id,
+ 'quantity' => 1,
+ ]);
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableB),
+ 'priceable_id' => $purchasableB->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableB),
+ 'purchasable_id' => $purchasableB->id,
+ 'quantity' => 1,
+ ]);
+
+ $discount = Discount::factory()->create([
+ 'type' => BuyXGetY::class,
+ 'name' => 'Test Product Discount',
+ 'data' => [
+ 'min_qty' => 1,
+ 'reward_qty' => 1,
+ ],
+ ]);
+
+ $discount->purchasableConditions()->create([
+ 'purchasable_type' => Product::class,
+ 'purchasable_id' => $productA->id,
+ ]);
+
+ $discount->purchasableRewards()->create([
+ 'purchasable_type' => Product::class,
+ 'purchasable_id' => $productB->id,
+ 'type' => 'reward',
+ ]);
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->subHour(),
+ ],
+ ]);
+
+ $cart = $cart->calculate();
+
+ $purchasableBCartLine = $cart->lines->first(function ($line) use ($purchasableB) {
+ return $line->purchasable_id == $purchasableB->id;
+ });
+
+ $this->assertEquals(1000, $purchasableBCartLine->discountTotal->value);
+ }
+
+ /**
+ * @test
+ * @group thisthis
+ */
+ public function can_discount_eligible_products()
+ {
+ $customerGroup = CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $currency = Currency::factory()->create([
+ 'code' => 'GBP',
+ ]);
+
+ $cart = Cart::factory()->create([
+ 'channel_id' => $channel->id,
+ 'currency_id' => $currency->id,
+ ]);
+
+ $productA = Product::factory()->create();
+ $productB = Product::factory()->create();
+ $productC = Product::factory()->create();
+
+ $purchasableA = ProductVariant::factory()->create([
+ 'product_id' => $productA->id,
+ ]);
+ $purchasableB = ProductVariant::factory()->create([
+ 'product_id' => $productB->id,
+ ]);
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableA),
+ 'priceable_id' => $purchasableA->id,
+ ]);
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableB),
+ 'priceable_id' => $purchasableB->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableA),
+ 'purchasable_id' => $purchasableA->id,
+ 'quantity' => 1,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableB),
+ 'purchasable_id' => $purchasableB->id,
+ 'quantity' => 1,
+ ]);
+
+ $discount = Discount::factory()->create([
+ 'type' => BuyXGetY::class,
+ 'name' => 'Test Product Discount',
+ 'data' => [
+ 'min_qty' => 1,
+ 'reward_qty' => 2,
+ ],
+ ]);
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->subHour(),
+ ],
+ ]);
+
+ $discount->purchasableConditions()->create([
+ 'purchasable_type' => Product::class,
+ 'purchasable_id' => $productA->id,
+ ]);
+
+ $discount->purchasableRewards()->create([
+ 'purchasable_type' => Product::class,
+ 'purchasable_id' => $productB->id,
+ 'type' => 'reward',
+ ]);
+
+ $cart = $cart->calculate();
+
+ $this->assertEquals(1200, $cart->total->value);
+ $this->assertCount(1, $cart->freeItems);
+ }
+}
diff --git a/packages/core/tests/Unit/DiscountTypes/DiscountTest.php b/packages/core/tests/Unit/DiscountTypes/DiscountTest.php
new file mode 100644
index 0000000000..aafcfd2207
--- /dev/null
+++ b/packages/core/tests/Unit/DiscountTypes/DiscountTest.php
@@ -0,0 +1,275 @@
+create([
+ 'default' => true,
+ ]);
+
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $currency = Currency::factory()->create([
+ 'code' => 'GBP',
+ ]);
+
+ $cart = Cart::factory()->create([
+ 'channel_id' => $channel->id,
+ 'currency_id' => $currency->id,
+ 'coupon_code' => '10OFF',
+ ]);
+
+ $brandA = Brand::factory()->create([
+ 'name' => 'Brand A',
+ ]);
+
+ $brandB = Brand::factory()->create([
+ 'name' => 'Brand B',
+ ]);
+
+ $productA = Product::factory()->create([
+ 'brand_id' => $brandA->id,
+ ]);
+
+ $productB = Product::factory()->create([
+ 'brand_id' => $brandB->id,
+ ]);
+
+ $purchasableA = ProductVariant::factory()->create([
+ 'product_id' => $productA->id,
+ ]);
+ $purchasableB = ProductVariant::factory()->create([
+ 'product_id' => $productB->id,
+ ]);
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableA),
+ 'priceable_id' => $purchasableA->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableA),
+ 'purchasable_id' => $purchasableA->id,
+ 'quantity' => 1,
+ ]);
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableB),
+ 'priceable_id' => $purchasableB->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableB),
+ 'purchasable_id' => $purchasableB->id,
+ 'quantity' => 1,
+ ]);
+
+ $discount = Discount::factory()->create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'coupon' => '10OFF',
+ 'data' => [
+ 'fixed_value' => false,
+ 'percentage' => 10,
+ ],
+ ]);
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->subHour(),
+ ],
+ ]);
+
+ $discount->brands()->sync([$brandA->id]);
+
+ $cart = $cart->calculate();
+
+ $this->assertEquals(100, $cart->discountTotal->value);
+ $this->assertEquals(2100, $cart->total->value);
+ }
+
+ /**
+ * @test
+ * @group thisdiscount
+ */
+ public function can_apply_fixed_amount_discount()
+ {
+ $currency = Currency::factory()->create([
+ 'code' => 'GBP',
+ ]);
+
+ $customerGroup = CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $cart = Cart::factory()->create([
+ 'currency_id' => $currency->id,
+ 'channel_id' => $channel->id,
+ 'coupon_code' => '10OFF',
+ ]);
+
+ $purchasableA = ProductVariant::factory()->create();
+
+ Price::factory()->create([
+ 'price' => 1000, // £10
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasableA),
+ 'priceable_id' => $purchasableA->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasableA),
+ 'purchasable_id' => $purchasableA->id,
+ 'quantity' => 2,
+ ]);
+
+ $discount = Discount::factory()->create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'coupon' => '10OFF',
+ 'data' => [
+ 'fixed_value' => true,
+ 'fixed_values' => [
+ 'GBP' => 10,
+ ],
+ ],
+ ]);
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->subHour(),
+ ],
+ ]);
+
+ $cart = $cart->calculate();
+
+ $this->assertEquals(1000, $cart->discountTotal->value);
+ $this->assertEquals(1400, $cart->total->value);
+ $this->assertEquals(400, $cart->taxTotal->value);
+ $this->assertCount(1, $cart->discounts);
+ }
+
+ /** @test */
+ public function can_apply_percentage_discount()
+ {
+ $customerGroup = CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $currency = Currency::factory()->create([
+ 'code' => 'GBP',
+ ]);
+
+ $cart = Cart::factory()->create([
+ 'channel_id' => $channel->id,
+ 'currency_id' => $currency->id,
+ 'coupon_code' => '10PERCENTOFF',
+ ]);
+
+ $purchasable = ProductVariant::factory()->create();
+
+ Price::factory()->create([
+ 'price' => 1000,
+ 'tier' => 1,
+ 'currency_id' => $currency->id,
+ 'priceable_type' => get_class($purchasable),
+ 'priceable_id' => $purchasable->id,
+ ]);
+
+ $cart->lines()->create([
+ 'purchasable_type' => get_class($purchasable),
+ 'purchasable_id' => $purchasable->id,
+ 'quantity' => 1,
+ ]);
+
+ $discount = Discount::factory()->create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'coupon' => '10PERCENTOFF',
+ 'data' => [
+ 'percentage' => 10,
+ 'fixed_value' => false,
+ ],
+ ]);
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->subHour(),
+ ],
+ ]);
+
+ $this->assertNull($cart->total);
+ $this->assertNull($cart->taxTotal);
+ $this->assertNull($cart->subTotal);
+
+ $cart = $cart->calculate();
+
+ $this->assertEquals(100, $cart->discountTotal->value);
+ $this->assertEquals(200, $cart->taxTotal->value);
+ $this->assertEquals(1100, $cart->total->value);
+ }
+}
diff --git a/packages/core/tests/Unit/Managers/DiscountManagerTest.php b/packages/core/tests/Unit/Managers/DiscountManagerTest.php
new file mode 100644
index 0000000000..65b7ac34ee
--- /dev/null
+++ b/packages/core/tests/Unit/Managers/DiscountManagerTest.php
@@ -0,0 +1,315 @@
+assertInstanceOf(DiscountManager::class, $manager);
+ }
+
+ /** @test */
+ public function can_set_channel()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ $channel = Channel::factory()->create();
+
+ $this->assertCount(0, $manager->getChannels());
+
+ $manager->channel($channel);
+
+ $this->assertCount(1, $manager->getChannels());
+
+ $channels = Channel::factory(2)->create();
+
+ $manager->channel($channels);
+
+ $this->assertCount(2, $manager->getChannels());
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $manager->channel(Product::factory(2)->create());
+ }
+
+ /** @test */
+ public function can_set_customer_group()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ $customerGroup = CustomerGroup::factory()->create();
+
+ $this->assertCount(0, $manager->getCustomerGroups());
+
+ $manager->customerGroup($customerGroup);
+
+ $this->assertCount(1, $manager->getCustomerGroups());
+
+ $customerGroups = CustomerGroup::factory(2)->create();
+
+ $manager->customerGroup($customerGroups);
+
+ $this->assertCount(2, $manager->getCustomerGroups());
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $manager->channel(Product::factory(2)->create());
+ }
+
+ /** @test */
+ public function can_restrict_discounts_to_channel()
+ {
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $channelTwo = Channel::factory()->create([
+ 'default' => false,
+ ]);
+
+ $customerGroup = CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
+ $discount = Discount::factory()->create();
+
+ $manager = app(DiscountManagerInterface::class);
+
+ $this->assertEmpty($manager->getDiscounts());
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'visible' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ $channelTwo->id => [
+ 'enabled' => false,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $this->assertCount(1, $manager->getDiscounts());
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->addHour(),
+ ],
+ $channelTwo->id => [
+ 'enabled' => false,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $this->assertEmpty($manager->getDiscounts());
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now()->subDay(),
+ 'ends_at' => now(),
+ ],
+ $channelTwo->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $this->assertEmpty($manager->getDiscounts());
+
+ $manager->channel($channelTwo);
+
+ $this->assertCount(1, $manager->getDiscounts());
+ }
+
+ /** @test */
+ public function can_restrict_discounts_to_customer_group()
+ {
+ $channel = Channel::factory()->create([
+ 'default' => true,
+ ]);
+
+ $customerGroup = CustomerGroup::factory()->create([
+ 'default' => true,
+ ]);
+
+ $customerGroupTwo = CustomerGroup::factory()->create([
+ 'default' => false,
+ ]);
+
+ $discount = Discount::factory()->create();
+
+ $discount->channels()->sync([
+ $channel->id => [
+ 'enabled' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'visible' => true,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $manager = app(DiscountManagerInterface::class);
+
+ $this->assertCount(1, $manager->getDiscounts());
+
+ $discount->customerGroups()->sync([
+ $channel->id => [
+ 'enabled' => false,
+ 'starts_at' => now(),
+ ],
+ ]);
+
+ $this->assertEmpty($manager->getDiscounts());
+
+ $discount->customerGroups()->sync([
+ $customerGroup->id => [
+ 'enabled' => true,
+ 'visible' => true,
+ 'starts_at' => now(),
+ ],
+ $customerGroupTwo->id => [
+ 'enabled' => true,
+ 'visible' => false,
+ 'starts_at' => null,
+ ],
+ ]);
+
+ $manager->customerGroup($customerGroupTwo);
+
+ $this->assertEmpty($manager->getDiscounts());
+ }
+
+ /**
+ * @test
+ */
+ public function can_fetch_discount_types()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ $this->assertInstanceOf(Collection::class, $manager->getTypes());
+ }
+
+ /**
+ * @test
+ */
+ public function can_fetch_applied_discounts()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ $this->assertInstanceOf(Collection::class, $manager->getApplied());
+ $this->assertCount(0, $manager->getApplied());
+ }
+
+ /**
+ * @test
+ */
+ public function can_add_applied_discount()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ $this->assertInstanceOf(Collection::class, $manager->getApplied());
+
+ $this->assertCount(0, $manager->getApplied());
+
+ ProductVariant::factory()->create();
+
+ $discount = Discount::factory()->create();
+ $cartLine = CartLine::factory()->create();
+
+ $discount = new CartDiscount(
+ model: $cartLine,
+ discount: $discount
+ );
+
+ $manager->addApplied($discount);
+
+ $this->assertCount(1, $manager->getApplied());
+ }
+
+ /**
+ * @test
+ */
+ public function can_add_new_types()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ $testType = $manager->getTypes()->first(function ($type) {
+ return get_class($type) == TestDiscountType::class;
+ });
+
+ $this->assertNull($testType);
+
+ $manager->addType(TestDiscountType::class);
+
+ $testType = $manager->getTypes()->first(function ($type) {
+ return get_class($type) == TestDiscountType::class;
+ });
+
+ $this->assertInstanceOf(TestDiscountType::class, $testType);
+ }
+
+ /** @test */
+ public function can_validate_coupons()
+ {
+ $manager = app(DiscountManagerInterface::class);
+
+ Discount::factory()->create([
+ 'type' => DiscountTypesDiscount::class,
+ 'name' => 'Test Coupon',
+ 'coupon' => '10OFF',
+ 'data' => [
+ 'fixed_value' => false,
+ 'percentage' => 10,
+ ],
+ ]);
+
+ $this->assertTrue(
+ $manager->validateCoupon('10OFF')
+ );
+
+ $this->assertFalse(
+ $manager->validateCoupon('20OFF')
+ );
+ }
+}
diff --git a/utils/livewire-tables/resources/views/columns/badge.blade.php b/utils/livewire-tables/resources/views/columns/badge.blade.php
index f781acba24..e834410c34 100644
--- a/utils/livewire-tables/resources/views/columns/badge.blade.php
+++ b/utils/livewire-tables/resources/views/columns/badge.blade.php
@@ -1,6 +1,7 @@
!@empty($info),
'lt-text-green-600 lt-bg-green-50' => !@empty($success),
'lt-text-blue-600 lt-bg-blue-50' => !@empty($info),
'lt-text-yellow-600 lt-bg-yellow-50' => !@empty(