| @@ -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) { |