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

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