PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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 / DiscountService.php

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

806 lines 29.3 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;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Models\AppliedCoupon;
7 use FluentCart\App\Models\Cart;
8 use FluentCart\App\Models\Coupon;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\Framework\Support\Arr;
11
12 class DiscountService
13 {
14 protected $cart = null;
15
16 protected $cartItems = [];
17
18 protected $customer = null;
19
20 protected $appliedCoupons = [];
21
22 protected $validCoupons = [];
23
24 protected $invalidCoupons = [];
25
26 protected $perCouponDiscounts = [];
27
28 public function __construct(?Cart $cart = null, $cartItems = [], $customer = null)
29 {
30 $this->cart = $cart;
31
32 if ($cartItems) {
33 $this->cartItems = $cartItems;
34 } else if ($cart) {
35 $this->cartItems = $cart->cart_data;
36 }
37
38 if ($customer) {
39 $this->customer = $customer;
40 }
41 }
42
43 public function resetIndividualItemsDiscounts()
44 {
45 foreach ($this->cartItems as &$item) {
46 $item['discount_total'] = Arr::get($item, 'manual_discount', 0);
47 $item['coupon_discount'] = 0;
48 $item['line_total'] = (int)($item['subtotal'] - $item['discount_total']);
49 if (isset($item['recurring_discounts'])) {
50 unset($item['recurring_discounts']);
51 }
52 }
53
54 $this->cartItems = array_values($this->cartItems);
55 $this->cart->cart_data = $this->cartItems;
56 $this->cart->save();
57 return $this;
58 }
59
60 public function revalidateCoupons()
61 {
62 if ($this->cart && $this->cart->coupons) {
63 return $this->applyCouponCodes($this->cart->coupons);
64 }
65
66 return new \WP_Error('no_coupons', __('No coupons found to revalidate.', 'fluent-cart'));
67 }
68
69 public function applyCouponCodes($codes = [])
70 {
71 if (!is_array($codes)) {
72 $codes = [$codes];
73 }
74
75 $existingCoupons = $this->cart ? $this->cart->coupons : [];
76
77 if (!$existingCoupons || !is_array($existingCoupons)) {
78 $existingCoupons = [];
79 }
80
81 $codes = array_merge($existingCoupons, $codes);
82
83 $codes = array_map('trim', $codes);
84 $codes = array_filter($codes);
85 $codes = array_unique($codes);
86 $codes = array_values($codes);
87
88 $coupons = Coupon::query()->whereIn('code', $codes)->get();
89
90 /*
91 * Allow addons to resolve codes that do not exist in fct_coupons into in-memory
92 * (virtual) Coupon models — e.g. a wallet / store-credit integration that applies a
93 * discount without persisting a coupon. The filter receives the DB-found coupons,
94 * the requested codes, and the cart; it may append unsaved Coupon instances.
95 */
96 $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $codes, [
97 'cart' => $this->cart,
98 ]);
99
100 if ($coupons->isEmpty()) {
101 return new \WP_Error('no_valid_coupons', __('No matching coupon found for this code.', 'fluent-cart'), []);
102 }
103
104 $invalidCoupons = [];
105
106 $formattedCoupons = $this->formatCoupons($coupons, $codes);
107 $validCoupons = [];
108
109 foreach ($formattedCoupons as $coupon) {
110 $validCoupon = $this->isCouponValid($coupon);
111 if (is_wp_error($validCoupon)) {
112 $invalidCoupons[$coupon->code] = [
113 'error' => $validCoupon->get_error_message(),
114 'error_code' => $validCoupon->get_error_code()
115 ];
116 } else {
117 $validCoupons[] = $coupon;
118 }
119 }
120
121 if (empty($validCoupons)) {
122 $message = __('Coupon can not be applied.', 'fluent-cart');
123 if (!empty($invalidCoupons)) {
124 $firstInvalid = reset($invalidCoupons);
125 if (!empty($firstInvalid['error'])) {
126 $message = $firstInvalid['error'];
127 }
128 }
129 return new \WP_Error('no_valid_coupons', $message, $invalidCoupons);
130 }
131
132 // Stacking contract — the first coupon applied always stays (parity with
133 // the admin path, CanValidateCoupon::canBeStacked). applyCouponCodes()
134 // merges existing cart coupons before newly submitted codes and
135 // formatCoupons() preserves that order, so index 0 is genuinely the
136 // first-applied valid coupon. A non-stackable first coupon locks the
137 // cart to itself; a stackable first admits only later stackable codes.
138 if (count($validCoupons) >= 2) {
139 $firstCoupon = $validCoupons[0];
140 $intermediateValidCoupons = [$firstCoupon];
141 foreach (array_slice($validCoupons, 1) as $coupon) {
142 if ($firstCoupon->stackable === 'yes' && $coupon->stackable === 'yes') {
143 $intermediateValidCoupons[] = $coupon;
144 } else {
145 $invalidCoupons[$coupon->code] = [
146 'success' => false,
147 'error' => __('This coupon cannot be stacked with other coupons.', 'fluent-cart'),
148 'error_code' => 'coupon_not_stackable'
149 ];
150 }
151 }
152
153 $validCoupons = $intermediateValidCoupons;
154 }
155
156 // Ensure stackable coupons are applied in priority order (lower value = higher priority)
157 if (count($validCoupons) >= 2) {
158 usort($validCoupons, function ($a, $b) {
159 $priorityA = isset($a->priority) ? (int)$a->priority : 0;
160 $priorityB = isset($b->priority) ? (int)$b->priority : 0;
161
162 if ($priorityA === $priorityB) {
163 return 0;
164 }
165
166 return ($priorityA < $priorityB) ? -1 : 1;
167 });
168 }
169
170 // Now we have all the valid and stackable coupons. Let's apply them to the cart.
171 $this->resetIndividualItemsDiscounts();
172
173 foreach ($validCoupons as $index => $coupon) {
174 $result = $this->apply($coupon);
175 if (is_wp_error($result)) {
176 $invalidCoupons[$coupon->code] = [
177 'success' => false,
178 'error' => $result->get_error_message(),
179 'error_code' => $result->get_error_code()
180 ];
181 unset($validCoupons[$index]);
182 }
183 }
184
185 $this->validCoupons = $validCoupons;
186 $this->invalidCoupons = $invalidCoupons;
187
188 return $this->getResult();
189 }
190
191 public function getResult()
192 {
193 $couponResults = $this->invalidCoupons;
194
195 foreach ($this->validCoupons as $validCoupon) {
196 $couponResults[$validCoupon->code] = [
197 'success' => true,
198 'coupon' => $validCoupon
199 ];
200 }
201
202 return [
203 'applied_coupon_codes' => $this->appliedCoupons,
204 'coupon_results' => $couponResults,
205 'cart_items' => $this->cartItems,
206 'per_coupon_discounts' => $this->perCouponDiscounts
207 ];
208 }
209
210 public function getCartItems()
211 {
212 return $this->cartItems;
213 }
214
215 public function getPerCouponDiscounts()
216 {
217 return $this->perCouponDiscounts;
218 }
219
220 public function getAppliedCoupons()
221 {
222 return $this->appliedCoupons;
223 }
224
225 public function apply(Coupon $coupon)
226 {
227 $cartItems = apply_filters('fluent_cart/discount/pre_apply', $this->cartItems, [
228 'coupon' => $coupon,
229 'cart' => $this->cart,
230 ]);
231
232 $canUseCheck = $this->checkCanUseCoupon($coupon, $cartItems);
233 if (is_wp_error($canUseCheck)) {
234 return $canUseCheck;
235 }
236
237 $preValidatedItems = $this->filterApplicableItems($cartItems, $coupon);
238 if (empty($preValidatedItems)) {
239 return new \WP_Error('no_applicable_items', __('No applicable items found for this coupon.', 'fluent-cart'));
240 }
241
242 $currentItemsSubtotal = $this->calculateItemsSubtotal($preValidatedItems);
243 $currentItemsDiscountTotal = $this->calculateExistingCouponDiscount($preValidatedItems);
244 $currentItemsTotalAfterDiscount = $currentItemsSubtotal - $currentItemsDiscountTotal;
245
246 if ($currentItemsTotalAfterDiscount <= 0) {
247 return new \WP_Error('items_already_discounted', __('The eligible items are already fully discounted by another coupon.', 'fluent-cart'));
248 }
249
250 $percent = $this->calculateDiscountPercent($coupon, $currentItemsTotalAfterDiscount);
251
252 // Snapshot per-item discounts before this coupon runs so the max-discount
253 // cap can trim only THIS coupon's contribution — stacked coupons applied
254 // earlier must keep their share untouched.
255 $preCouponDiscounts = [];
256 $preRecurringDiscounts = [];
257 foreach ($preValidatedItems as $preItem) {
258 $preCouponDiscounts[$preItem['id']] = (int) Arr::get($preItem, 'coupon_discount', 0);
259 $preRecurringDiscounts[$preItem['id']] = (int) Arr::get($preItem, 'recurring_discounts.amount', 0);
260 }
261
262 list($preValidatedItems, $couponDiscountTotal) = $this->applyDiscountToItems($preValidatedItems, $percent, $coupon);
263
264 if ($coupon->type === 'fixed') {
265 list($preValidatedItems, $couponDiscountTotal) = $this->correctFixedCouponRounding(
266 $preValidatedItems, $coupon, $couponDiscountTotal
267 );
268 }
269
270 $maxDiscountAmount = (int) Arr::get($coupon->conditions, 'max_discount_amount', 0);
271 if ($maxDiscountAmount > 0) {
272 list($preValidatedItems, $couponDiscountTotal) = $this->capDiscountAtMax(
273 $preValidatedItems, 'coupon_discount', $maxDiscountAmount, $preCouponDiscounts
274 );
275 // The per-renewal discount must honor the same cap, otherwise every
276 // renewal charge overshoots it.
277 list($preValidatedItems) = $this->capDiscountAtMax(
278 $preValidatedItems, 'recurring_discounts.amount', $maxDiscountAmount, $preRecurringDiscounts
279 );
280 }
281
282 $cartItems = $this->mergeValidatedItems($cartItems, $preValidatedItems);
283
284 if (!$couponDiscountTotal) {
285 return new \WP_Error('no_discount_applied', __('This coupon does not provide any additional discount on your order.', 'fluent-cart'));
286 }
287
288 $cartItems = $this->updateItemTotals($cartItems);
289
290 $this->cartItems = array_values($cartItems);
291 $this->appliedCoupons[] = $coupon->code;
292 $this->perCouponDiscounts[$coupon->code] = $couponDiscountTotal;
293
294 return true;
295 }
296
297 private function checkCanUseCoupon(Coupon $coupon, array $cartItems)
298 {
299 $canUse = apply_filters('fluent_cart/coupon/can_use_coupon', true, [
300 'coupon' => $coupon,
301 'cart' => $this->cart,
302 'cart_items' => $cartItems,
303 ]);
304
305 if (!$canUse || is_wp_error($canUse)) {
306 $message = __('This coupon is not available for your order.', 'fluent-cart');
307 if (is_wp_error($canUse)) {
308 $message = $canUse->get_error_message();
309 }
310 return new \WP_Error('coupon_cannot_be_used', $message);
311 }
312
313 return true;
314 }
315
316 private function filterApplicableItems(array $cartItems, Coupon $coupon)
317 {
318 $conditions = $coupon->conditions;
319
320 $filtered = array_filter($cartItems, function ($item) use ($coupon, $conditions) {
321 $willPreSkip = apply_filters('fluent_cart/coupon/will_skip_item', false, [
322 'item' => $item,
323 'coupon' => $coupon,
324 'cart' => $this->cart
325 ]);
326
327 if ($willPreSkip || Arr::get($item, 'other_info.is_locked') === 'yes') {
328 return false;
329 }
330
331 $excludedProducts = Arr::get($conditions, 'excluded_products', []);
332 if ($excludedProducts && in_array($item['object_id'], $excludedProducts)) {
333 return false;
334 }
335
336 $includedProducts = Arr::get($conditions, 'included_products', []);
337 if (!is_array($includedProducts)) {
338 $includedProducts = [];
339 }
340 if ($includedProducts && !in_array($item['object_id'], $includedProducts)) {
341 return false;
342 }
343
344 $includedCategories = Arr::get($conditions, 'included_categories', []);
345 if (!is_array($includedCategories)) {
346 $includedCategories = [];
347 }
348
349 $excludedCategories = Arr::get($conditions, 'excluded_categories', []);
350 if (!is_array($excludedCategories)) {
351 $excludedCategories = [];
352 }
353
354 if ($includedCategories || $excludedCategories) {
355 $productCategoryIds = $this->getProductCategories(Arr::get($item, 'post_id'));
356 if ($includedCategories) {
357 $intersect = array_intersect($includedCategories, $productCategoryIds);
358 if (empty($intersect)) {
359 return false;
360 }
361 }
362
363 if ($excludedCategories) {
364 $intersect = array_intersect($excludedCategories, $productCategoryIds);
365 if (!empty($intersect)) {
366 return false;
367 }
368 }
369 }
370
371 $emailRestrictions = trim(Arr::get($conditions, 'email_restrictions', ''));
372 if ($emailRestrictions) {
373 $customerEmail = $this->cart ? $this->cart->email : '';
374 if (!$customerEmail) {
375 return false;
376 }
377
378 $allowedEmails = array_filter(array_map('trim', explode(',', $emailRestrictions)));
379 if ($allowedEmails) {
380 foreach ($allowedEmails as $email) {
381 $pattern = '/^' . str_replace('\*', '.*', preg_quote($email, '/')) . '$/i';
382 if (preg_match($pattern, $customerEmail)) {
383 return true;
384 }
385 }
386
387 return false;
388 }
389 }
390
391 return true;
392 });
393
394 return array_values(array_filter($filtered));
395 }
396
397 private function calculateItemsSubtotal(array $items)
398 {
399 return array_sum(array_map(function ($item) {
400 return $this->getItemEffectiveSubtotal($item);
401 }, $items));
402 }
403
404 private function calculateExistingCouponDiscount(array $items)
405 {
406 return array_sum(array_map(function ($item) {
407 return (int) Arr::get($item, 'coupon_discount', 0);
408 }, $items));
409 }
410
411 private function calculateDiscountPercent(Coupon $coupon, $totalAfterDiscount)
412 {
413 if ($coupon->type == 'fixed') {
414 if ($coupon->amount >= $totalAfterDiscount) {
415 return 100.0;
416 }
417 return round(($coupon->amount / $totalAfterDiscount) * 100, 2);
418 }
419
420 return round(min(100, max(0, (float) $coupon->amount)), 2);
421 }
422
423 private function applyDiscountToItems(array $items, $percent, Coupon $coupon)
424 {
425 $couponDiscountTotal = 0;
426
427 foreach ($items as $index => $item) {
428 $existingAmount = (int) Arr::get($item, 'coupon_discount', 0);
429 $itemSubtotal = $this->getItemEffectiveSubtotal($item);
430 $hasTrialDays = Arr::get($item, 'other_info.payment_type') === 'subscription'
431 && Arr::get($item, 'other_info.trial_days', 0) > 0;
432
433 $remainingTotal = max(0, $itemSubtotal - $existingAmount);
434 $currentDiscount = (int) round($remainingTotal * ($percent / 100));
435 $discountTotal = min($existingAmount + $currentDiscount, $itemSubtotal);
436 $netDiscount = max(0, $discountTotal - $existingAmount);
437
438 $couponDiscountTotal += $netDiscount;
439 $items[$index]['coupon_discount'] = $discountTotal;
440
441 // Apply recurring discount for non-trial subscriptions
442 if (Arr::get($item, 'other_info.payment_type') === 'subscription' && !$hasTrialDays) {
443 if (!isset($items[$index]['recurring_discounts'])) {
444 $items[$index]['recurring_discounts'] = [
445 'signup' => 0,
446 'amount' => 0
447 ];
448 }
449
450 if ($coupon->isRecurringDiscount()) {
451 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
452 if ($unitPrice > 0) {
453 $previousAmount = (int) Arr::get($item, 'recurring_discounts.amount', 0);
454 $remainingRecurring = max(0, $unitPrice - $previousAmount);
455 $recurringDiscount = (int) round($remainingRecurring * ($percent / 100));
456 $totalRecurringDiscount = min($previousAmount + $recurringDiscount, $unitPrice);
457
458 Arr::set($items, $index . '.recurring_discounts.amount', $totalRecurringDiscount);
459 }
460 }
461 }
462 }
463
464 return [$items, $couponDiscountTotal];
465 }
466
467 /**
468 * Clamp this coupon's total contribution under $valueKey to $maxAmount,
469 * scaling each item's share proportionally (cents in, cents out).
470 *
471 * $preValues holds each item's value before this coupon ran, keyed by item
472 * id — only the delta above it (this coupon's share) is ever reduced.
473 *
474 * @return array [items, appliedTotalForThisCoupon]
475 */
476 private function capDiscountAtMax(array $items, $valueKey, $maxAmount, array $preValues)
477 {
478 $shares = [];
479 $totalShare = 0;
480 foreach ($items as $index => $item) {
481 $current = (int) Arr::get($item, $valueKey, 0);
482 $pre = (int) Arr::get($preValues, $item['id'], 0);
483 $share = max(0, $current - $pre);
484 if ($share > 0) {
485 $shares[$index] = $share;
486 $totalShare += $share;
487 }
488 }
489
490 if ($totalShare <= $maxAmount) {
491 return [$items, $totalShare];
492 }
493
494 $capped = [];
495 $cappedTotal = 0;
496 foreach ($shares as $index => $share) {
497 $cappedShare = (int) floor(($share * $maxAmount) / $totalShare);
498 $capped[$index] = $cappedShare;
499 $cappedTotal += $cappedShare;
500 }
501
502 // floor() can leave a few cents of the cap unassigned — hand them out
503 // to items that still have room so the total lands exactly on the cap.
504 $leftover = $maxAmount - $cappedTotal;
505 foreach ($shares as $index => $share) {
506 if ($leftover <= 0) {
507 break;
508 }
509 $room = $share - $capped[$index];
510 if ($room <= 0) {
511 continue;
512 }
513 $add = min($room, $leftover);
514 $capped[$index] += $add;
515 $leftover -= $add;
516 }
517
518 foreach ($capped as $index => $cappedShare) {
519 $pre = (int) Arr::get($preValues, $items[$index]['id'], 0);
520 Arr::set($items, $index . '.' . $valueKey, $pre + $cappedShare);
521 }
522
523 return [$items, $maxAmount];
524 }
525
526 private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal)
527 {
528 if ($couponDiscountTotal < $coupon->amount) {
529 $remainingAmount = $coupon->amount - $couponDiscountTotal;
530 foreach ($items as $index => $item) {
531 if ($remainingAmount <= 0) {
532 break;
533 }
534
535 $subtotal = $this->getItemEffectiveSubtotal($item);
536 $maximumReduction = (int) ($subtotal - Arr::get($item, 'coupon_discount', 0));
537 if ($maximumReduction <= 0) {
538 continue;
539 }
540
541 $newDiscountAmount = min($maximumReduction, $remainingAmount);
542 $items[$index]['coupon_discount'] = Arr::get($item, 'coupon_discount', 0) + $newDiscountAmount;
543 $couponDiscountTotal += $newDiscountAmount;
544 $remainingAmount -= $newDiscountAmount;
545 }
546 } else if ($couponDiscountTotal > $coupon->amount) {
547 $excessAmount = $couponDiscountTotal - $coupon->amount;
548 foreach ($items as $index => $item) {
549 if ($excessAmount <= 0) {
550 break;
551 }
552
553 $existingDiscount = Arr::get($item, 'coupon_discount', 0);
554 if ($existingDiscount <= 0) {
555 continue;
556 }
557
558 $newReductionAmount = min($existingDiscount, $excessAmount);
559 $items[$index]['coupon_discount'] = $existingDiscount - $newReductionAmount;
560 $couponDiscountTotal -= $newReductionAmount;
561 $excessAmount -= $newReductionAmount;
562 }
563 }
564
565 return [$items, $couponDiscountTotal];
566 }
567
568 private function mergeValidatedItems(array $cartItems, array $validatedItems)
569 {
570 foreach ($cartItems as $index => $item) {
571 foreach ($validatedItems as $preItem) {
572 if ($item['id'] == $preItem['id']) {
573 $cartItems[$index] = $preItem;
574 break;
575 }
576 }
577 }
578
579 return $cartItems;
580 }
581
582 private function updateItemTotals(array $cartItems)
583 {
584 foreach ($cartItems as &$item) {
585 $item['discount_total'] = (int) (Arr::get($item, 'manual_discount', 0) + Arr::get($item, 'coupon_discount', 0));
586 $subtotal = $this->getItemEffectiveSubtotal($item);
587 $item['line_total'] = max(0, (int) ($subtotal - $item['discount_total']));
588 }
589
590 return $cartItems;
591 }
592
593 private function getItemEffectiveSubtotal(array $item)
594 {
595 if (Arr::get($item, 'other_info.payment_type') === 'subscription'
596 && Arr::get($item, 'other_info.trial_days', 0) > 0
597 ) {
598 $quantity = max(1, (int) Arr::get($item, 'quantity', 1));
599 // When dynamic RC has already adjusted signup_fee to the net amount, use the
600 // pre-adjustment gross value so the coupon always applies to the original price.
601 $signupFee = Arr::get($item, 'other_info.original_signup_fee') !== null
602 ? (int) Arr::get($item, 'other_info.original_signup_fee')
603 : (int) Arr::get($item, 'other_info.signup_fee', 0);
604 return $signupFee * $quantity;
605 }
606
607 // When dynamic RC has already reduced unit_price to the net (tax-stripped) amount,
608 // use the saved gross price so the coupon is always calculated against the original
609 // inclusive price — regardless of whether VAT number was entered before or after
610 // the coupon was applied.
611 $originalUnitPrice = Arr::get($item, 'line_meta.original_unit_price');
612 if ($originalUnitPrice !== null) {
613 $quantity = max(1, (int) Arr::get($item, 'quantity', 1));
614 return (int) $originalUnitPrice * $quantity;
615 }
616
617 return (int) $item['subtotal'];
618 }
619
620 public function saveCart()
621 {
622 if (!$this->cart) {
623 return new \WP_Error('no_cart', __('No cart found to save.', 'fluent-cart'));
624 }
625
626 $existingCheckoutData = $this->cart->checkout_data;
627
628 if (!is_array($existingCheckoutData)) {
629 $existingCheckoutData = [];
630 }
631
632 $existingCheckoutData['__per_coupon_discounts'] = $this->perCouponDiscounts;
633
634 $this->cart->cart_data = $this->cartItems;
635 $this->cart->coupons = $this->appliedCoupons;
636 $this->cart->save();
637 return $this->cart;
638 }
639
640 protected function formatCoupons($coupons, $codes)
641 {
642 $coupons = $coupons->keyBy('code');
643 $formatted = [];
644
645 foreach ($codes as $code) {
646 if (isset($coupons[$code])) {
647 $formatted[] = $coupons[$code];
648 }
649 }
650
651 return $formatted;
652 }
653
654 protected function isCouponValid($coupon)
655 {
656 $status = $coupon->status;
657 if ($status === 'expired') {
658 return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart'));
659 }
660 if ($status === 'scheduled') {
661 return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart'));
662 }
663 if ($status !== 'active') {
664 return new \WP_Error('coupon_not_available', __('This coupon is not currently available.', 'fluent-cart'));
665 }
666
667 // let's validate the start date and end date first
668 $startDate = $coupon->start_date;
669 if ($startDate && $startDate != '0000-00-00 00:00:00' && strtotime($startDate) > time()) {
670 return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart'));
671 }
672 $endDate = $coupon->end_date;
673 if ($endDate && $endDate != '0000-00-00 00:00:00' && strtotime($endDate) < time()) {
674 return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart'));
675 }
676
677 $conditions = $coupon->conditions;
678
679 // The spend limits below (min/max) are measured against either the cart subtotal
680 // (items only) or the full order total (shipping + fees included), per the coupon's
681 // min_amount_basis setting. Coupons created before this setting existed have no stored
682 // value and historically compared against the order total, so the fallback stays 'total'
683 // to preserve their behavior. New coupons default to 'subtotal' in the admin UI.
684 $amountBasis = Arr::get($conditions, 'min_amount_basis', 'total');
685
686 // add check max_purchase_amount
687 $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0);
688 $getCartTotal = 0;
689 if ($this->cart) {
690 $cartAmount = $amountBasis === 'total'
691 ? $this->cart->getEstimatedTotal()
692 : $this->cart->getItemsSubtotal();
693 $getCartTotal = ($cartAmount / 100);
694 }
695
696 if ($maxPurchaseAmount) {
697 if ($getCartTotal > $maxPurchaseAmount) {
698 return new \WP_Error('max_purchase_amount_exceeded', __('Your cart total exceeds the maximum amount allowed for this coupon.', 'fluent-cart'));
699 }
700 }
701
702 $minPurchaseAmount = Arr::get($conditions, 'min_purchase_amount', 0);
703 if ($minPurchaseAmount) {
704 if ($getCartTotal < ($minPurchaseAmount / 100)) {
705 return new \WP_Error('min_purchase_amount_not_met', __('Your cart total is below the minimum required to use this coupon.', 'fluent-cart'));
706 }
707 }
708
709 // Let's check the use count and max uses
710 $useCount = $coupon->use_count;
711 $maxUses = Arr::get($conditions, 'max_uses', 0);
712 if ($useCount && $maxUses && $useCount >= $maxUses) {
713 return new \WP_Error('coupon_max_uses_exceeded', __('This coupon has reached its maximum number of uses.', 'fluent-cart'));
714 }
715 $maxPerCustomer = Arr::get($conditions, 'max_per_customer', 0);
716 if ($maxPerCustomer) {
717 if (!is_user_logged_in()) {
718 return new \WP_Error('coupon_login_required', __('Please log in to use this coupon.', 'fluent-cart'));
719 }
720
721 $customer = $this->resolveCustomerForUsageLimit();
722 if ($customer) {
723 $usageQuery = AppliedCoupon::query()
724 ->where('coupon_id', $coupon->id)
725 ->whereHas('order', function ($orderQuery) use ($customer) {
726 $orderQuery->where('customer_id', $customer->id);
727 });
728
729 $usageQuery = apply_filters('fluent_cart/coupon/per_customer_usage_query', $usageQuery, [
730 'coupon' => $coupon,
731 'customer' => $customer,
732 'cart' => $this->cart,
733 ]);
734
735 $usedCount = $usageQuery->count();
736
737 if ($usedCount >= $maxPerCustomer) {
738 return new \WP_Error('coupon_max_uses_exceeded', __('You have already used this coupon the maximum number of times.', 'fluent-cart'));
739 }
740 }
741 }
742
743 return $coupon;
744 }
745
746 protected function resolveCustomerForUsageLimit()
747 {
748 if (!is_user_logged_in()) {
749 return null;
750 }
751
752 $customer = $this->getCustomer();
753 if ($customer) {
754 return $customer;
755 }
756
757 $customer = Customer::query()->where('user_id', get_current_user_id())->first();
758 if ($customer) {
759 $this->customer = $customer;
760 return $customer;
761 }
762
763 return null;
764 }
765
766 public function setCustomer(Customer $customer)
767 {
768 $this->customer = $customer;
769 }
770
771 public function getCustomer()
772 {
773 if ($this->customer) {
774 return $this->customer;
775 }
776
777 if ($this->cart) {
778 $this->customer = $this->cart->guessCustomer();
779 return $this->customer;
780 }
781
782 return null;
783 }
784
785 protected function getProductCategories($postId)
786 {
787 static $cached = [];
788
789 if (isset($cached[$postId])) {
790 return $cached[$postId];
791 }
792
793
794 $taxonomyName = 'product-categories';
795 $terms = get_the_terms($postId, $taxonomyName);
796 if (is_wp_error($terms) || !$terms) {
797 $cached[$postId] = [];
798 } else {
799 $cached[$postId] = wp_list_pluck($terms, 'term_id');
800 }
801
802 return $cached[$postId];
803 }
804
805 }
806