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 +103 -10 1.5.0 → 1.6.6 View file →
@@ -128,13 +128,19 @@
128 128 }
129 129 return new \WP_Error('no_valid_coupons', $message, $invalidCoupons);
130 130 }
131 131
132 - // 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.
133 138 if (count($validCoupons) >= 2) {
134 - $intermediateValidCoupons = [];
135 - foreach ($validCoupons as $coupon) {
136 - 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') {
137 143 $intermediateValidCoupons[] = $coupon;
138 144 } else {
139 145 $invalidCoupons[$coupon->code] = [
140 146 'success' => false,
@@ -143,13 +149,9 @@
143 149 ];
144 150 }
145 151 }
146 152
147 - if (!$intermediateValidCoupons) {
148 - $validCoupons = [$validCoupons[0]];
149 - } else {
150 - $validCoupons = $intermediateValidCoupons;
151 - }
153 + $validCoupons = $intermediateValidCoupons;
152 154 }
153 155
154 156 // Ensure stackable coupons are applied in priority order (lower value = higher priority)
155 157 if (count($validCoupons) >= 2) {
@@ -246,8 +248,18 @@
246 248 }
247 249
248 250 $percent = $this->calculateDiscountPercent($coupon, $currentItemsTotalAfterDiscount);
249 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 +
250 262 list($preValidatedItems, $couponDiscountTotal) = $this->applyDiscountToItems($preValidatedItems, $percent, $coupon);
251 263
252 264 if ($coupon->type === 'fixed') {
253 265 list($preValidatedItems, $couponDiscountTotal) = $this->correctFixedCouponRounding(
@@ -254,8 +266,20 @@
254 266 $preValidatedItems, $coupon, $couponDiscountTotal
255 267 );
256 268 }
257 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 +
258 282 $cartItems = $this->mergeValidatedItems($cartItems, $preValidatedItems);
259 283
260 284 if (!$couponDiscountTotal) {
261 285 return new \WP_Error('no_discount_applied', __('This coupon does not provide any additional discount on your order.', 'fluent-cart'));
@@ -439,8 +463,67 @@
439 463
440 464 return [$items, $couponDiscountTotal];
441 465 }
442 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 +
443 526 private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal)
444 527 {
445 528 if ($couponDiscountTotal < $coupon->amount) {
446 529 $remainingAmount = $coupon->amount - $couponDiscountTotal;
@@ -592,13 +675,23 @@
592 675 }
593 676
594 677 $conditions = $coupon->conditions;
595 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 +
596 686 // add check max_purchase_amount
597 687 $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0);
598 688 $getCartTotal = 0;
599 689 if ($this->cart) {
600 - $getCartTotal = ($this->cart->getEstimatedTotal() / 100);
690 + $cartAmount = $amountBasis === 'total'
691 + ? $this->cart->getEstimatedTotal()
692 + : $this->cart->getItemsSubtotal();
693 + $getCartTotal = ($cartAmount / 100);
601 694 }
602 695
603 696 if ($maxPurchaseAmount) {
604 697 if ($getCartTotal > $maxPurchaseAmount) {