PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / Coupon / Concerns / CanValidateCoupon.php

CanValidateCoupon.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.2, at app/Services/Coupon/Concerns/CanValidateCoupon.php

355 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Coupon\Concerns;
4
5 use FluentCart\App\Models\AppliedCoupon;
6 use FluentCart\App\Models\Coupon;
7 use FluentCart\App\Models\Customer;
8 use FluentCart\App\Services\DateTime\DateTime;
9 use FluentCart\App\Services\OrderService;
10 use FluentCart\Framework\Support\Arr;
11 use FluentCart\Framework\Support\Collection;
12 use WP_Error;
13
14 trait CanValidateCoupon
15 {
16 /**
17 * @return bool|WP_Error
18 */
19 public function validate($couponCode)
20 {
21 $couponCode = apply_filters('fluent_cart/coupon/validating_coupon', $couponCode,
22 [
23 'coupon_code' => $couponCode,
24 'line_items' => $this->lineItems,
25 'couponService' => $this
26 ]
27 );
28
29 if (is_wp_error($couponCode)) {
30 return $couponCode;
31 }
32
33 if (empty($couponCode)) {
34 return $this->makeError(__('Invalid Coupon Code', 'fluent-cart'), 404);
35 }
36
37 $coupon = $this->applicableCoupons->firstWhere('code', $couponCode);
38
39 if (empty($coupon)) {
40 return $this->makeError(__('Coupon Not Found', 'fluent-cart'), 404);
41 }
42
43 if ($this->isAlreadyApplied($coupon)) {
44 return $this->makeError(__('Coupon already applied', 'fluent-cart'), 401);
45 }
46
47 //Return true at this point is not using cart, because at this point coupon is already used in an order
48 if (!$this->usingCart && $this->previouslyAppliedCoupons->has($couponCode)) {
49 return true;
50 }
51
52 if (!$this->canBeStacked($coupon)) {
53 return $this->makeError(__('Can not apply the coupons together', 'fluent-cart'), 401);
54 }
55
56 if (!$this->isCouponActive($coupon)) {
57
58 return $this->makeError(__('This coupon has expired or is not valid', 'fluent-cart'), 401);
59 }
60
61 if ($this->requiredUserToBeLoggedIn($coupon)) {
62
63 if (!is_user_logged_in()) {
64 return $this->makeError(__('You Need To be logged in', 'fluent-cart'), 403);
65 }
66
67 if (!$this->hasMaxPerUserLimit($coupon)) {
68 return $this->makeError(__('Coupon Uses Limit Exceeded', 'fluent-cart'), 401);
69 }
70 }
71
72 if (!$this->hasUseLimit($coupon)) {
73 return $this->makeError(__('Coupon Uses Limit Exceeded', 'fluent-cart'), 403);
74 }
75
76 if (!$this->ensureMinimumPurchaseAmount($coupon)) {
77 return $this->makeError(__('Purchase amount is smaller than required amount', 'fluent-cart'), 403);
78 }
79
80 if (!$this->ensureMaximumPurchaseAmount($coupon)) {
81 return $this->makeError(__('Your cart total exceeds the maximum amount allowed for this coupon.', 'fluent-cart'), 403);
82 }
83
84 if (!$this->matchesEmailRestrictions($coupon)) {
85 return $this->makeError(__('This coupon is restricted to specific email addresses', 'fluent-cart'), 403);
86 }
87
88 if (!$this->isProductValidated($coupon)) {
89 return $this->makeError(__('No applicable products in the cart for this coupon', 'fluent-cart'), 403);
90 }
91
92 return true;
93 }
94
95 public function isProductValidated($coupon): bool
96 {
97 foreach ($this->lineItems as $item) {
98 if ($this->isApplicableToProduct($coupon, $item['post_id'], $item['id'])) {
99 return true;
100 }
101 }
102 return false;
103 }
104
105 public function canBeStacked(Coupon $coupon): bool
106 {
107 //If there is no applied coupon return true;
108 if (empty($this->previouslyAppliedCouponCodes)) {
109 return true;
110 }
111 //If there is any applied coupon, that is not stackable return false;
112 foreach ($this->previouslyAppliedCoupons as $lcoupon) {
113 if (Arr::get($lcoupon, 'stackable') === 'no') {
114 return false;
115 }
116 }
117
118 //return true if the coupon is stackable
119 return (Arr::get($coupon, 'stackable') !== 'no');
120 }
121
122 public function isAlreadyApplied(Coupon $coupon): bool
123 {
124 return in_array($coupon->code, $this->previouslyAppliedCouponCodes);
125 }
126
127 public function ensureMinimumPurchaseAmount(Coupon $coupon): bool
128 {
129 $min_purchase_amount = Arr::get($coupon->conditions, 'min_purchase_amount', null);
130 if (empty($min_purchase_amount)) {
131 return true;
132 } else {
133 // The coupon's min_amount_basis ('subtotal' | 'total') governs whether shipping counts
134 // toward this gate. This admin order-editing path operates on line items only and has no
135 // live shipping context, so the check is always against the items subtotal here — the
136 // 'total' (shipping-inclusive) basis takes effect on the storefront checkout path
137 // (see DiscountService::isCouponValid).
138 $orderTotal = OrderService::getItemsAmountTotal($this->lineItems, false, false);
139 return $min_purchase_amount <= $orderTotal;
140 }
141 }
142
143 public function ensureMaximumPurchaseAmount(Coupon $coupon): bool
144 {
145 $maxPurchaseAmount = Arr::get($coupon->conditions, 'max_purchase_amount', null);
146 if (empty($maxPurchaseAmount)) {
147 return true;
148 }
149
150 // Unlike min_purchase_amount (converted to cents by CouponResource::formatCouponData),
151 // max_purchase_amount is stored as the decimal amount the admin typed — so the
152 // cents-denominated items total must come down to decimal before comparing, exactly
153 // as DiscountService::isCouponValid does on the storefront path.
154 $orderTotal = OrderService::getItemsAmountTotal($this->lineItems, false, false);
155
156 return ($orderTotal / 100) <= $maxPurchaseAmount;
157 }
158
159 public function matchesEmailRestrictions(Coupon $coupon): bool
160 {
161 $emailRestrictions = trim((string) Arr::get($coupon->conditions, 'email_restrictions', ''));
162 if (!$emailRestrictions) {
163 return true;
164 }
165
166 $allowedEmails = array_filter(array_map('trim', explode(',', $emailRestrictions)));
167 if (!$allowedEmails) {
168 return true;
169 }
170
171 // A restricted coupon needs a known customer email to match against —
172 // same contract as the storefront path (DiscountService), which also
173 // refuses when the cart has no email yet.
174 if (!$this->customerEmail) {
175 return false;
176 }
177
178 foreach ($allowedEmails as $email) {
179 $pattern = '/^' . str_replace('\*', '.*', preg_quote($email, '/')) . '$/i';
180 if (preg_match($pattern, $this->customerEmail)) {
181 return true;
182 }
183 }
184
185 return false;
186 }
187
188 public function isCouponActive(Coupon $coupon): bool
189 {
190 $status = $coupon->status;
191 $now = DateTime::gmtNow();
192
193 $hasExpirationDate = true;
194 $hasStartDate = true;
195
196 if (empty($coupon->start_date) || $coupon->start_date === '0000-00-00 00:00:00') {
197 $hasStartDate = false;
198 }
199
200 if (empty($coupon->end_date) || $coupon->end_date === '0000-00-00 00:00:00') {
201 $hasExpirationDate = false;
202 }
203
204
205 if ($status === 'active' && !$hasExpirationDate && !$hasStartDate) {
206 return true;
207 }
208
209
210 if ($status === 'scheduled' || $status === 'active') {
211
212 $startDate = null;
213 $endDate = null;
214 $couponStarted = false;
215 $couponEnded = false;
216
217 if (!empty($coupon->start_date)) {
218 $startDate = DateTime::parse($coupon->start_date);
219 $couponStarted = $startDate <= $now;
220 }
221
222 if (!empty($coupon->end_date)) {
223 $endDate = DateTime::parse($coupon->end_date);
224 $couponEnded = $endDate < $now;
225 }
226
227
228
229
230 if (empty($startDate))
231 return !$couponEnded;
232 if (empty($endDate))
233 return $couponStarted;
234
235 return $couponStarted && !$couponEnded;
236 }
237
238 return false;
239 }
240
241 public function hasUseLimit(Coupon $coupon): bool
242 {
243 $maxUse = Arr::get($coupon->conditions, 'max_uses', null);
244 if (empty($maxUse)) {
245 return true;
246 }
247
248 if (empty($coupon->use_count)) {
249 return true;
250 }
251
252 return $maxUse > $coupon->use_count;
253 }
254
255 public function requiredUserToBeLoggedIn(Coupon $coupon): bool
256 {
257 return Arr::get($coupon->conditions, 'max_per_customer', 0);
258 }
259
260 public function hasMaxPerUserLimit(Coupon $coupon): bool
261 {
262 $maxPerCustomer = Arr::get($coupon->conditions, 'max_per_customer', 0);
263 if (empty($maxPerCustomer)) {
264 return true;
265 }
266
267 $userId = get_current_user_id();
268
269 $customer = Customer::query()->where('user_id', $userId)->first();
270
271 if (empty($customer)) {
272 return true;
273 }
274
275 // fct_applied_coupons has no customer_id column — the customer is
276 // reachable only through the order the coupon row belongs to.
277 // coupon_id is nullable: legacy rows carry only the code, and skipping
278 // them would let a capped customer reuse the coupon.
279 $usedCount = AppliedCoupon::query()
280 ->where(function ($query) use ($coupon) {
281 $query->where('coupon_id', $coupon->id)
282 ->orWhere(function ($legacyQuery) use ($coupon) {
283 $legacyQuery->whereNull('coupon_id')->where('code', $coupon->code);
284 });
285 })
286 ->whereHas('order', function ($orderQuery) use ($customer) {
287 $orderQuery->where('customer_id', $customer->id);
288 })
289 ->count();
290
291 return $maxPerCustomer > $usedCount;
292 }
293
294 public function isApplicableToProduct(Coupon $coupon, $productId, $variationsId): bool
295 {
296 if (empty($variationsId)) {
297 return false;
298 }
299
300 $categories = Arr::get($this->products, ($productId . '') . '.categories');
301
302 $categoryIds = Collection::make($categories)->pluck('term_id')->toArray();
303
304 $canBeApplied = true;
305
306 $conditions = $coupon->conditions;
307
308 $excludedCategories = $this->getArrayValue($conditions, 'excluded_categories');
309 $includedCategories = $this->getArrayValue($conditions, 'included_categories');
310 $excludedProducts = $this->getArrayValue($conditions, 'excluded_products');
311 $includedProducts = $this->getArrayValue($conditions, 'included_products');
312
313 if (in_array($variationsId, $includedProducts)) {
314 return true;
315 }
316
317 if (in_array($variationsId, $excludedProducts)) {
318 return false;
319 }
320
321 foreach ($categoryIds as $categoryId) {
322 if (empty($categoryId)) {
323 continue;
324 }
325 if (in_array($categoryId, $excludedCategories)) {
326 $canBeApplied = false;
327 }
328 if (!empty($includedCategories) && !in_array($categoryId, $includedCategories)) {
329 $canBeApplied = false;
330 }
331
332 if (!$canBeApplied) {
333 break;
334 }
335 }
336
337 if (!empty($includedProducts) && !in_array($variationsId, $includedProducts)) {
338 return false;
339 }
340
341
342 return $canBeApplied;
343 }
344
345 protected function makeError(string $message, $code = null): WP_Error
346 {
347 return new WP_Error($code, $message);
348 }
349
350 private function getArrayValue($array, $key, $defaultValue = [])
351 {
352 return isset($array[$key]) && is_array($array[$key]) ? $array[$key] : $defaultValue;
353 }
354 }
355