PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.28
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.28
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.3.28, at app/Services/Coupon/DiscountService.php

688 lines 23.7 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 if ($coupons->isEmpty()) {
91 return new \WP_Error('no_valid_coupons', __('No matching coupon found for this code.', 'fluent-cart'), []);
92 }
93
94 $invalidCoupons = [];
95
96 $formattedCoupons = $this->formatCoupons($coupons, $codes);
97 $validCoupons = [];
98
99 foreach ($formattedCoupons as $coupon) {
100 $validCoupon = $this->isCouponValid($coupon);
101 if (is_wp_error($validCoupon)) {
102 $invalidCoupons[$coupon->code] = [
103 'error' => $validCoupon->get_error_message(),
104 'error_code' => $validCoupon->get_error_code()
105 ];
106 } else {
107 $validCoupons[] = $coupon;
108 }
109 }
110
111 if (empty($validCoupons)) {
112 $message = __('Coupon can not be applied.', 'fluent-cart');
113 if (!empty($invalidCoupons)) {
114 $firstInvalid = reset($invalidCoupons);
115 if (!empty($firstInvalid['error'])) {
116 $message = $firstInvalid['error'];
117 }
118 }
119 return new \WP_Error('no_valid_coupons', $message, $invalidCoupons);
120 }
121
122 // Let's check if we have multiple coupons and if they are stackable. If not, we will only keep the first one and invalidate the rest.
123 if (count($validCoupons) >= 2) {
124 $intermediateValidCoupons = [];
125 foreach ($validCoupons as $coupon) {
126 if ($coupon->stackable === 'yes') {
127 $intermediateValidCoupons[] = $coupon;
128 } else {
129 $invalidCoupons[$coupon->code] = [
130 'success' => false,
131 'error' => __('This coupon cannot be stacked with other coupons.', 'fluent-cart'),
132 'error_code' => 'coupon_not_stackable'
133 ];
134 }
135 }
136
137 if (!$intermediateValidCoupons) {
138 $validCoupons = [$validCoupons[0]];
139 } else {
140 $validCoupons = $intermediateValidCoupons;
141 }
142 }
143
144 // Ensure stackable coupons are applied in priority order (lower value = higher priority)
145 if (count($validCoupons) >= 2) {
146 usort($validCoupons, function ($a, $b) {
147 $priorityA = isset($a->priority) ? (int)$a->priority : 0;
148 $priorityB = isset($b->priority) ? (int)$b->priority : 0;
149
150 if ($priorityA === $priorityB) {
151 return 0;
152 }
153
154 return ($priorityA < $priorityB) ? -1 : 1;
155 });
156 }
157
158 // Now we have all the valid and stackable coupons. Let's apply them to the cart.
159 $this->resetIndividualItemsDiscounts();
160
161 foreach ($validCoupons as $index => $coupon) {
162 $result = $this->apply($coupon);
163 if (is_wp_error($result)) {
164 $invalidCoupons[$coupon->code] = [
165 'success' => false,
166 'error' => $result->get_error_message(),
167 'error_code' => $result->get_error_code()
168 ];
169 unset($validCoupons[$index]);
170 }
171 }
172
173 $this->validCoupons = $validCoupons;
174 $this->invalidCoupons = $invalidCoupons;
175
176 return $this->getResult();
177 }
178
179 public function getResult()
180 {
181 $couponResults = $this->invalidCoupons;
182
183 foreach ($this->validCoupons as $validCoupon) {
184 $couponResults[$validCoupon->code] = [
185 'success' => true,
186 'coupon' => $validCoupon
187 ];
188 }
189
190 return [
191 'applied_coupon_codes' => $this->appliedCoupons,
192 'coupon_results' => $couponResults,
193 'cart_items' => $this->cartItems,
194 'per_coupon_discounts' => $this->perCouponDiscounts
195 ];
196 }
197
198 public function getCartItems()
199 {
200 return $this->cartItems;
201 }
202
203 public function getPerCouponDiscounts()
204 {
205 return $this->perCouponDiscounts;
206 }
207
208 public function getAppliedCoupons()
209 {
210 return $this->appliedCoupons;
211 }
212
213 public function apply(Coupon $coupon)
214 {
215 $cartItems = apply_filters('fluent_cart/discount/pre_apply', $this->cartItems, [
216 'coupon' => $coupon,
217 'cart' => $this->cart,
218 ]);
219
220 $canUseCheck = $this->checkCanUseCoupon($coupon, $cartItems);
221 if (is_wp_error($canUseCheck)) {
222 return $canUseCheck;
223 }
224
225 $preValidatedItems = $this->filterApplicableItems($cartItems, $coupon);
226 if (empty($preValidatedItems)) {
227 return new \WP_Error('no_applicable_items', __('No applicable items found for this coupon.', 'fluent-cart'));
228 }
229
230 $currentItemsSubtotal = $this->calculateItemsSubtotal($preValidatedItems);
231 $currentItemsDiscountTotal = $this->calculateExistingCouponDiscount($preValidatedItems);
232 $currentItemsTotalAfterDiscount = $currentItemsSubtotal - $currentItemsDiscountTotal;
233
234 if ($currentItemsTotalAfterDiscount <= 0) {
235 return new \WP_Error('items_already_discounted', __('The eligible items are already fully discounted by another coupon.', 'fluent-cart'));
236 }
237
238 $percent = $this->calculateDiscountPercent($coupon, $currentItemsTotalAfterDiscount);
239
240 list($preValidatedItems, $couponDiscountTotal) = $this->applyDiscountToItems($preValidatedItems, $percent, $coupon);
241
242 if ($coupon->type === 'fixed') {
243 list($preValidatedItems, $couponDiscountTotal) = $this->correctFixedCouponRounding(
244 $preValidatedItems, $coupon, $couponDiscountTotal
245 );
246 }
247
248 $cartItems = $this->mergeValidatedItems($cartItems, $preValidatedItems);
249
250 if (!$couponDiscountTotal) {
251 return new \WP_Error('no_discount_applied', __('This coupon does not provide any additional discount on your order.', 'fluent-cart'));
252 }
253
254 $cartItems = $this->updateItemTotals($cartItems);
255
256 $this->cartItems = array_values($cartItems);
257 $this->appliedCoupons[] = $coupon->code;
258 $this->perCouponDiscounts[$coupon->code] = $couponDiscountTotal;
259
260 return true;
261 }
262
263 private function checkCanUseCoupon(Coupon $coupon, array $cartItems)
264 {
265 $canUse = apply_filters('fluent_cart/coupon/can_use_coupon', true, [
266 'coupon' => $coupon,
267 'cart' => $this->cart,
268 'cart_items' => $cartItems,
269 ]);
270
271 if (!$canUse || is_wp_error($canUse)) {
272 $message = __('This coupon is not available for your order.', 'fluent-cart');
273 if (is_wp_error($canUse)) {
274 $message = $canUse->get_error_message();
275 }
276 return new \WP_Error('coupon_cannot_be_used', $message);
277 }
278
279 return true;
280 }
281
282 private function filterApplicableItems(array $cartItems, Coupon $coupon)
283 {
284 $conditions = $coupon->conditions;
285
286 $filtered = array_filter($cartItems, function ($item) use ($coupon, $conditions) {
287 $willPreSkip = apply_filters('fluent_cart/coupon/will_skip_item', false, [
288 'item' => $item,
289 'coupon' => $coupon,
290 'cart' => $this->cart
291 ]);
292
293 if ($willPreSkip || Arr::get($item, 'other_info.is_locked') === 'yes') {
294 return false;
295 }
296
297 $excludedProducts = Arr::get($conditions, 'excluded_products', []);
298 if ($excludedProducts && in_array($item['object_id'], $excludedProducts)) {
299 return false;
300 }
301
302 $includedProducts = Arr::get($conditions, 'included_products', []);
303 if (!is_array($includedProducts)) {
304 $includedProducts = [];
305 }
306 if ($includedProducts && !in_array($item['object_id'], $includedProducts)) {
307 return false;
308 }
309
310 $includedCategories = Arr::get($conditions, 'included_categories', []);
311 if (!is_array($includedCategories)) {
312 $includedCategories = [];
313 }
314
315 $excludedCategories = Arr::get($conditions, 'excluded_categories', []);
316 if (!is_array($excludedCategories)) {
317 $excludedCategories = [];
318 }
319
320 if ($includedCategories || $excludedCategories) {
321 $productCategoryIds = $this->getProductCategories(Arr::get($item, 'post_id'));
322 if ($includedCategories) {
323 $intersect = array_intersect($includedCategories, $productCategoryIds);
324 if (empty($intersect)) {
325 return false;
326 }
327 }
328
329 if ($excludedCategories) {
330 $intersect = array_intersect($excludedCategories, $productCategoryIds);
331 if (!empty($intersect)) {
332 return false;
333 }
334 }
335 }
336
337 $emailRestrictions = trim(Arr::get($conditions, 'email_restrictions', ''));
338 if ($emailRestrictions) {
339 $customerEmail = $this->cart ? $this->cart->email : '';
340 if (!$customerEmail) {
341 return false;
342 }
343
344 $allowedEmails = array_filter(array_map('trim', explode(',', $emailRestrictions)));
345 if ($allowedEmails) {
346 foreach ($allowedEmails as $email) {
347 $pattern = '/^' . str_replace('\*', '.*', preg_quote($email, '/')) . '$/i';
348 if (preg_match($pattern, $customerEmail)) {
349 return true;
350 }
351 }
352
353 return false;
354 }
355 }
356
357 return true;
358 });
359
360 return array_values(array_filter($filtered));
361 }
362
363 private function calculateItemsSubtotal(array $items)
364 {
365 return array_sum(array_map(function ($item) {
366 return $this->getItemEffectiveSubtotal($item);
367 }, $items));
368 }
369
370 private function calculateExistingCouponDiscount(array $items)
371 {
372 return array_sum(array_map(function ($item) {
373 return (int) Arr::get($item, 'coupon_discount', 0);
374 }, $items));
375 }
376
377 private function calculateDiscountPercent(Coupon $coupon, $totalAfterDiscount)
378 {
379 if ($coupon->type == 'fixed') {
380 if ($coupon->amount >= $totalAfterDiscount) {
381 return 100.0;
382 }
383 return round(($coupon->amount / $totalAfterDiscount) * 100, 2);
384 }
385
386 return round(min(100, max(0, (float) $coupon->amount)), 2);
387 }
388
389 private function applyDiscountToItems(array $items, $percent, Coupon $coupon)
390 {
391 $couponDiscountTotal = 0;
392
393 foreach ($items as $index => $item) {
394 $existingAmount = (int) Arr::get($item, 'coupon_discount', 0);
395 $itemSubtotal = $this->getItemEffectiveSubtotal($item);
396 $hasTrialDays = Arr::get($item, 'other_info.payment_type') === 'subscription'
397 && Arr::get($item, 'other_info.trial_days', 0) > 0;
398
399 $remainingTotal = max(0, $itemSubtotal - $existingAmount);
400 $currentDiscount = (int) round($remainingTotal * ($percent / 100));
401 $discountTotal = min($existingAmount + $currentDiscount, $itemSubtotal);
402 $netDiscount = max(0, $discountTotal - $existingAmount);
403
404 $couponDiscountTotal += $netDiscount;
405 $items[$index]['coupon_discount'] = $discountTotal;
406
407 // Apply recurring discount for non-trial subscriptions
408 if (Arr::get($item, 'other_info.payment_type') === 'subscription' && !$hasTrialDays) {
409 if (!isset($items[$index]['recurring_discounts'])) {
410 $items[$index]['recurring_discounts'] = [
411 'signup' => 0,
412 'amount' => 0
413 ];
414 }
415
416 if ($coupon->isRecurringDiscount()) {
417 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
418 if ($unitPrice > 0) {
419 $previousAmount = (int) Arr::get($item, 'recurring_discounts.amount', 0);
420 $remainingRecurring = max(0, $unitPrice - $previousAmount);
421 $recurringDiscount = (int) round($remainingRecurring * ($percent / 100));
422 $totalRecurringDiscount = min($previousAmount + $recurringDiscount, $unitPrice);
423
424 Arr::set($items, $index . '.recurring_discounts.amount', $totalRecurringDiscount);
425 }
426 }
427 }
428 }
429
430 return [$items, $couponDiscountTotal];
431 }
432
433 private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal)
434 {
435 if ($couponDiscountTotal < $coupon->amount) {
436 $remainingAmount = $coupon->amount - $couponDiscountTotal;
437 foreach ($items as $index => $item) {
438 if ($remainingAmount <= 0) {
439 break;
440 }
441
442 $subtotal = $this->getItemEffectiveSubtotal($item);
443 $maximumReduction = (int) ($subtotal - Arr::get($item, 'coupon_discount', 0));
444 if ($maximumReduction <= 0) {
445 continue;
446 }
447
448 $newDiscountAmount = min($maximumReduction, $remainingAmount);
449 $items[$index]['coupon_discount'] = Arr::get($item, 'coupon_discount', 0) + $newDiscountAmount;
450 $couponDiscountTotal += $newDiscountAmount;
451 $remainingAmount -= $newDiscountAmount;
452 }
453 } else if ($couponDiscountTotal > $coupon->amount) {
454 $excessAmount = $couponDiscountTotal - $coupon->amount;
455 foreach ($items as $index => $item) {
456 if ($excessAmount <= 0) {
457 break;
458 }
459
460 $existingDiscount = Arr::get($item, 'coupon_discount', 0);
461 if ($existingDiscount <= 0) {
462 continue;
463 }
464
465 $newReductionAmount = min($existingDiscount, $excessAmount);
466 $items[$index]['coupon_discount'] = $existingDiscount - $newReductionAmount;
467 $couponDiscountTotal -= $newReductionAmount;
468 $excessAmount -= $newReductionAmount;
469 }
470 }
471
472 return [$items, $couponDiscountTotal];
473 }
474
475 private function mergeValidatedItems(array $cartItems, array $validatedItems)
476 {
477 foreach ($cartItems as $index => $item) {
478 foreach ($validatedItems as $preItem) {
479 if ($item['id'] == $preItem['id']) {
480 $cartItems[$index] = $preItem;
481 break;
482 }
483 }
484 }
485
486 return $cartItems;
487 }
488
489 private function updateItemTotals(array $cartItems)
490 {
491 foreach ($cartItems as &$item) {
492 $item['discount_total'] = (int) (Arr::get($item, 'manual_discount', 0) + Arr::get($item, 'coupon_discount', 0));
493 $subtotal = $this->getItemEffectiveSubtotal($item);
494 $item['line_total'] = max(0, (int) ($subtotal - $item['discount_total']));
495 }
496
497 return $cartItems;
498 }
499
500 private function getItemEffectiveSubtotal(array $item)
501 {
502 $subtotal = (int) $item['subtotal'];
503 if (Arr::get($item, 'other_info.payment_type') === 'subscription'
504 && Arr::get($item, 'other_info.trial_days', 0) > 0
505 ) {
506 $quantity = (int) Arr::get($item, 'quantity', 1);
507 $subtotal = (int) Arr::get($item, 'other_info.signup_fee', 0) * ($quantity > 0 ? $quantity : 1);
508 }
509 return $subtotal;
510 }
511
512 public function saveCart()
513 {
514 if (!$this->cart) {
515 return new \WP_Error('no_cart', __('No cart found to save.', 'fluent-cart'));
516 }
517
518 $existingCheckoutData = $this->cart->checkout_data;
519
520 if (!is_array($existingCheckoutData)) {
521 $existingCheckoutData = [];
522 }
523
524 $existingCheckoutData['__per_coupon_discounts'] = $this->perCouponDiscounts;
525
526 $this->cart->cart_data = $this->cartItems;
527 $this->cart->coupons = $this->appliedCoupons;
528 $this->cart->save();
529 return $this->cart;
530 }
531
532 protected function formatCoupons($coupons, $codes)
533 {
534 $coupons = $coupons->keyBy('code');
535 $formatted = [];
536
537 foreach ($codes as $code) {
538 if (isset($coupons[$code])) {
539 $formatted[] = $coupons[$code];
540 }
541 }
542
543 return $formatted;
544 }
545
546 protected function isCouponValid($coupon)
547 {
548 $status = $coupon->status;
549 if ($status === 'expired') {
550 return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart'));
551 }
552 if ($status === 'scheduled') {
553 return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart'));
554 }
555 if ($status !== 'active') {
556 return new \WP_Error('coupon_not_available', __('This coupon is not currently available.', 'fluent-cart'));
557 }
558
559 // let's validate the start date and end date first
560 $startDate = $coupon->start_date;
561 if ($startDate && $startDate != '0000-00-00 00:00:00' && strtotime($startDate) > time()) {
562 return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart'));
563 }
564 $endDate = $coupon->end_date;
565 if ($endDate && $endDate != '0000-00-00 00:00:00' && strtotime($endDate) < time()) {
566 return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart'));
567 }
568
569 $conditions = $coupon->conditions;
570
571 // add check max_purchase_amount
572 $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0);
573 $getCartTotal = 0;
574 if ($this->cart) {
575 $getCartTotal = ($this->cart->getEstimatedTotal() / 100);
576 }
577
578 if ($maxPurchaseAmount) {
579 if ($getCartTotal > $maxPurchaseAmount) {
580 return new \WP_Error('max_purchase_amount_exceeded', __('Your cart total exceeds the maximum amount allowed for this coupon.', 'fluent-cart'));
581 }
582 }
583
584 $minPurchaseAmount = Arr::get($conditions, 'min_purchase_amount', 0);
585 if ($minPurchaseAmount) {
586 if ($getCartTotal < ($minPurchaseAmount / 100)) {
587 return new \WP_Error('min_purchase_amount_not_met', __('Your cart total is below the minimum required to use this coupon.', 'fluent-cart'));
588 }
589 }
590
591 // Let's check the use count and max uses
592 $useCount = $coupon->use_count;
593 $maxUses = Arr::get($conditions, 'max_uses', 0);
594 if ($useCount && $maxUses && $useCount >= $maxUses) {
595 return new \WP_Error('coupon_max_uses_exceeded', __('This coupon has reached its maximum number of uses.', 'fluent-cart'));
596 }
597 $maxPerCustomer = Arr::get($conditions, 'max_per_customer', 0);
598 if ($maxPerCustomer) {
599 if (!is_user_logged_in()) {
600 return new \WP_Error('coupon_login_required', __('Please log in to use this coupon.', 'fluent-cart'));
601 }
602
603 $customer = $this->resolveCustomerForUsageLimit();
604 if ($customer) {
605 $usageQuery = AppliedCoupon::query()
606 ->where('coupon_id', $coupon->id)
607 ->whereHas('order', function ($orderQuery) use ($customer) {
608 $orderQuery->where('customer_id', $customer->id);
609 });
610
611 $usageQuery = apply_filters('fluent_cart/coupon/per_customer_usage_query', $usageQuery, [
612 'coupon' => $coupon,
613 'customer' => $customer,
614 'cart' => $this->cart,
615 ]);
616
617 $usedCount = $usageQuery->count();
618
619 if ($usedCount >= $maxPerCustomer) {
620 return new \WP_Error('coupon_max_uses_exceeded', __('You have already used this coupon the maximum number of times.', 'fluent-cart'));
621 }
622 }
623 }
624
625 return $coupon;
626 }
627
628 protected function resolveCustomerForUsageLimit()
629 {
630 if (!is_user_logged_in()) {
631 return null;
632 }
633
634 $customer = $this->getCustomer();
635 if ($customer) {
636 return $customer;
637 }
638
639 $customer = Customer::query()->where('user_id', get_current_user_id())->first();
640 if ($customer) {
641 $this->customer = $customer;
642 return $customer;
643 }
644
645 return null;
646 }
647
648 public function setCustomer(Customer $customer)
649 {
650 $this->customer = $customer;
651 }
652
653 public function getCustomer()
654 {
655 if ($this->customer) {
656 return $this->customer;
657 }
658
659 if ($this->cart) {
660 $this->customer = $this->cart->guessCustomer();
661 return $this->customer;
662 }
663
664 return null;
665 }
666
667 protected function getProductCategories($postId)
668 {
669 static $cached = [];
670
671 if (isset($cached[$postId])) {
672 return $cached[$postId];
673 }
674
675
676 $taxonomyName = 'product-categories';
677 $terms = get_the_terms($postId, $taxonomyName);
678 if (is_wp_error($terms) || !$terms) {
679 $cached[$postId] = [];
680 } else {
681 $cached[$postId] = wp_list_pluck($terms, 'term_id');
682 }
683
684 return $cached[$postId];
685 }
686
687 }
688