PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 +92 -9 1.6.0 → 1.6.5 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'));
@@ -437,8 +461,67 @@
437 461 }
438 462 }
439 463
440 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];
441 524 }
442 525
443 526 private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal)
444 527 {