PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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
← All changes | app/Services/Coupon/DiscountService.php +161 -27 1.3.25 → 1.6.6 View file →
@@ -84,14 +84,22 @@
84 84 $codes = array_filter($codes);
85 85 $codes = array_unique($codes);
86 86 $codes = array_values($codes);
87 87
88 - $coupons = Coupon::query()->whereIn('code', $codes)
89 - ->where('status', 'active')
90 - ->get();
88 + $coupons = Coupon::query()->whereIn('code', $codes)->get();
91 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 +
92 100 if ($coupons->isEmpty()) {
93 - return new \WP_Error('no_valid_coupons', __('Coupon can not be applied.', 'fluent-cart'), []);
101 + return new \WP_Error('no_valid_coupons', __('No matching coupon found for this code.', 'fluent-cart'), []);
94 102 }
95 103
96 104 $invalidCoupons = [];
97 105
@@ -110,16 +118,29 @@
110 118 }
111 119 }
112 120
113 121 if (empty($validCoupons)) {
114 - return new \WP_Error('no_valid_coupons', __('Coupon can not be applied.', 'fluent-cart'), $invalidCoupons);
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);
115 130 }
116 131
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.
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.
118 138 if (count($validCoupons) >= 2) {
119 - $intermediateValidCoupons = [];
120 - foreach ($validCoupons as $coupon) {
121 - if ($coupon->stackable === 'yes') {
139 + $firstCoupon = $validCoupons[0];
140 + $intermediateValidCoupons = [$firstCoupon];
141 + foreach (array_slice($validCoupons, 1) as $coupon) {
142 + if ($firstCoupon->stackable === 'yes' && $coupon->stackable === 'yes') {
122 143 $intermediateValidCoupons[] = $coupon;
123 144 } else {
124 145 $invalidCoupons[$coupon->code] = [
125 146 'success' => false,
@@ -128,13 +149,9 @@
128 149 ];
129 150 }
130 151 }
131 152
132 - if (!$intermediateValidCoupons) {
133 - $validCoupons = [$validCoupons[0]];
134 - } else {
135 - $validCoupons = $intermediateValidCoupons;
136 - }
153 + $validCoupons = $intermediateValidCoupons;
137 154 }
138 155
139 156 // Ensure stackable coupons are applied in priority order (lower value = higher priority)
140 157 if (count($validCoupons) >= 2) {
@@ -226,13 +243,23 @@
226 243 $currentItemsDiscountTotal = $this->calculateExistingCouponDiscount($preValidatedItems);
227 244 $currentItemsTotalAfterDiscount = $currentItemsSubtotal - $currentItemsDiscountTotal;
228 245
229 246 if ($currentItemsTotalAfterDiscount <= 0) {
230 - return new \WP_Error('no_applicable_items', __('No applicable items found for this coupon.', 'fluent-cart'));
247 + return new \WP_Error('items_already_discounted', __('The eligible items are already fully discounted by another coupon.', 'fluent-cart'));
231 248 }
232 249
233 250 $percent = $this->calculateDiscountPercent($coupon, $currentItemsTotalAfterDiscount);
234 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 +
235 262 list($preValidatedItems, $couponDiscountTotal) = $this->applyDiscountToItems($preValidatedItems, $percent, $coupon);
236 263
237 264 if ($coupon->type === 'fixed') {
238 265 list($preValidatedItems, $couponDiscountTotal) = $this->correctFixedCouponRounding(
@@ -239,12 +266,24 @@
239 266 $preValidatedItems, $coupon, $couponDiscountTotal
240 267 );
241 268 }
242 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 +
243 282 $cartItems = $this->mergeValidatedItems($cartItems, $preValidatedItems);
244 283
245 284 if (!$couponDiscountTotal) {
246 - return new \WP_Error('no_discount_applied', __('This coupon could not apply any discount.', 'fluent-cart'));
285 + return new \WP_Error('no_discount_applied', __('This coupon does not provide any additional discount on your order.', 'fluent-cart'));
247 286 }
248 287
249 288 $cartItems = $this->updateItemTotals($cartItems);
250 289
@@ -263,9 +302,9 @@
263 302 'cart_items' => $cartItems,
264 303 ]);
265 304
266 305 if (!$canUse || is_wp_error($canUse)) {
267 - $message = __('This coupon cannot be used.', 'fluent-cart');
306 + $message = __('This coupon is not available for your order.', 'fluent-cart');
268 307 if (is_wp_error($canUse)) {
269 308 $message = $canUse->get_error_message();
270 309 }
271 310 return new \WP_Error('coupon_cannot_be_used', $message);
@@ -424,8 +463,67 @@
424 463
425 464 return [$items, $couponDiscountTotal];
426 465 }
427 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 +
428 526 private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal)
429 527 {
430 528 if ($couponDiscountTotal < $coupon->amount) {
431 529 $remainingAmount = $coupon->amount - $couponDiscountTotal;
@@ -493,16 +591,31 @@
493 591 }
494 592
495 593 private function getItemEffectiveSubtotal(array $item)
496 594 {
497 - $subtotal = (int) $item['subtotal'];
498 595 if (Arr::get($item, 'other_info.payment_type') === 'subscription'
499 596 && Arr::get($item, 'other_info.trial_days', 0) > 0
500 597 ) {
501 - $quantity = (int) Arr::get($item, 'quantity', 1);
502 - $subtotal = (int) Arr::get($item, 'other_info.signup_fee', 0) * ($quantity > 0 ? $quantity : 1);
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;
503 605 }
504 - return $subtotal;
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'];
505 618 }
506 619
507 620 public function saveCart()
508 621 {
@@ -539,30 +652,51 @@
539 652 }
540 653
541 654 protected function isCouponValid($coupon)
542 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 +
543 667 // let's validate the start date and end date first
544 668 $startDate = $coupon->start_date;
545 669 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'));
670 + return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart'));
547 671 }
548 672 $endDate = $coupon->end_date;
549 673 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'));
674 + return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart'));
551 675 }
552 676
553 677 $conditions = $coupon->conditions;
554 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 +
555 686 // add check max_purchase_amount
556 687 $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0);
557 688 $getCartTotal = 0;
558 689 if ($this->cart) {
559 - $getCartTotal = ($this->cart->getEstimatedTotal() / 100);
690 + $cartAmount = $amountBasis === 'total'
691 + ? $this->cart->getEstimatedTotal()
692 + : $this->cart->getItemsSubtotal();
693 + $getCartTotal = ($cartAmount / 100);
560 694 }
561 695
562 696 if ($maxPurchaseAmount) {
563 697 if ($getCartTotal > $maxPurchaseAmount) {
564 - return new \WP_Error('max_purchase_amount_exceeded', __('This coupon is no longer valid.', 'fluent-cart'));
698 + return new \WP_Error('max_purchase_amount_exceeded', __('Your cart total exceeds the maximum amount allowed for this coupon.', 'fluent-cart'));
565 699 }
566 700 }
567 701
568 702 $minPurchaseAmount = Arr::get($conditions, 'min_purchase_amount', 0);
@@ -567,9 +701,9 @@
567 701
568 702 $minPurchaseAmount = Arr::get($conditions, 'min_purchase_amount', 0);
569 703 if ($minPurchaseAmount) {
570 704 if ($getCartTotal < ($minPurchaseAmount / 100)) {
571 - return new \WP_Error('min_purchase_amount_not_met', __('This coupon is no longer valid.', 'fluent-cart'));
705 + return new \WP_Error('min_purchase_amount_not_met', __('Your cart total is below the minimum required to use this coupon.', 'fluent-cart'));
572 706 }
573 707 }
574 708
575 709 // Let's check the use count and max uses
@@ -600,9 +734,9 @@
600 734
601 735 $usedCount = $usageQuery->count();
602 736
603 737 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'));
738 + return new \WP_Error('coupon_max_uses_exceeded', __('You have already used this coupon the maximum number of times.', 'fluent-cart'));
605 739 }
606 740 }
607 741 }
608 742