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 +113 -10 1.4.0 → 1.6.5 View file →
@@ -86,8 +86,18 @@
86 86 $codes = array_values($codes);
87 87
88 88 $coupons = Coupon::query()->whereIn('code', $codes)->get();
89 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 +
90 100 if ($coupons->isEmpty()) {
91 101 return new \WP_Error('no_valid_coupons', __('No matching coupon found for this code.', 'fluent-cart'), []);
92 102 }
93 103
@@ -118,13 +128,19 @@
118 128 }
119 129 return new \WP_Error('no_valid_coupons', $message, $invalidCoupons);
120 130 }
121 131
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.
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.
123 138 if (count($validCoupons) >= 2) {
124 - $intermediateValidCoupons = [];
125 - foreach ($validCoupons as $coupon) {
126 - 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') {
127 143 $intermediateValidCoupons[] = $coupon;
128 144 } else {
129 145 $invalidCoupons[$coupon->code] = [
130 146 'success' => false,
@@ -133,13 +149,9 @@
133 149 ];
134 150 }
135 151 }
136 152
137 - if (!$intermediateValidCoupons) {
138 - $validCoupons = [$validCoupons[0]];
139 - } else {
140 - $validCoupons = $intermediateValidCoupons;
141 - }
153 + $validCoupons = $intermediateValidCoupons;
142 154 }
143 155
144 156 // Ensure stackable coupons are applied in priority order (lower value = higher priority)
145 157 if (count($validCoupons) >= 2) {
@@ -236,8 +248,18 @@
236 248 }
237 249
238 250 $percent = $this->calculateDiscountPercent($coupon, $currentItemsTotalAfterDiscount);
239 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 +
240 262 list($preValidatedItems, $couponDiscountTotal) = $this->applyDiscountToItems($preValidatedItems, $percent, $coupon);
241 263
242 264 if ($coupon->type === 'fixed') {
243 265 list($preValidatedItems, $couponDiscountTotal) = $this->correctFixedCouponRounding(
@@ -244,8 +266,20 @@
244 266 $preValidatedItems, $coupon, $couponDiscountTotal
245 267 );
246 268 }
247 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 +
248 282 $cartItems = $this->mergeValidatedItems($cartItems, $preValidatedItems);
249 283
250 284 if (!$couponDiscountTotal) {
251 285 return new \WP_Error('no_discount_applied', __('This coupon does not provide any additional discount on your order.', 'fluent-cart'));
@@ -429,8 +463,67 @@
429 463
430 464 return [$items, $couponDiscountTotal];
431 465 }
432 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 +
433 526 private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal)
434 527 {
435 528 if ($couponDiscountTotal < $coupon->amount) {
436 529 $remainingAmount = $coupon->amount - $couponDiscountTotal;
@@ -582,13 +675,23 @@
582 675 }
583 676
584 677 $conditions = $coupon->conditions;
585 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 +
586 686 // add check max_purchase_amount
587 687 $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0);
588 688 $getCartTotal = 0;
589 689 if ($this->cart) {
590 - $getCartTotal = ($this->cart->getEstimatedTotal() / 100);
690 + $cartAmount = $amountBasis === 'total'
691 + ? $this->cart->getEstimatedTotal()
692 + : $this->cart->getItemsSubtotal();
693 + $getCartTotal = ($cartAmount / 100);
591 694 }
592 695
593 696 if ($maxPurchaseAmount) {
594 697 if ($getCartTotal > $maxPurchaseAmount) {