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 +132 -14 1.3.28 → 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;
@@ -498,16 +591,31 @@
498 591 }
499 592
500 593 private function getItemEffectiveSubtotal(array $item)
501 594 {
502 - $subtotal = (int) $item['subtotal'];
503 595 if (Arr::get($item, 'other_info.payment_type') === 'subscription'
504 596 && Arr::get($item, 'other_info.trial_days', 0) > 0
505 597 ) {
506 - $quantity = (int) Arr::get($item, 'quantity', 1);
507 - $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;
508 605 }
509 - 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'];
510 618 }
511 619
512 620 public function saveCart()
513 621 {
@@ -567,13 +675,23 @@
567 675 }
568 676
569 677 $conditions = $coupon->conditions;
570 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 +
571 686 // add check max_purchase_amount
572 687 $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0);
573 688 $getCartTotal = 0;
574 689 if ($this->cart) {
575 - $getCartTotal = ($this->cart->getEstimatedTotal() / 100);
690 + $cartAmount = $amountBasis === 'total'
691 + ? $this->cart->getEstimatedTotal()
692 + : $this->cart->getItemsSubtotal();
693 + $getCartTotal = ($cartAmount / 100);
576 694 }
577 695
578 696 if ($maxPurchaseAmount) {
579 697 if ($getCartTotal > $maxPurchaseAmount) {