| @@ -84,14 +84,22 @@ | ||
| 84 | 84 | $codes = array_filter($codes); |
| 85 | 85 | $codes = array_unique($codes); |
| 86 | 86 | $codes = array_values($codes); |
| 87 | 87 | |
| 88 | - $coupons = Coupon::query()->whereIn('code', $codes) | |
| 89 | - ->where('status', 'active') | |
| 90 | - ->get(); | |
| 88 | + $coupons = Coupon::query()->whereIn('code', $codes)->get(); | |
| 91 | 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 | + | |
| 92 | 100 | if ($coupons->isEmpty()) { |
| 93 | - return new \WP_Error('no_valid_coupons', __('Coupon can not be applied.', 'fluent-cart'), []); | |
| 101 | + return new \WP_Error('no_valid_coupons', __('No matching coupon found for this code.', 'fluent-cart'), []); | |
| 94 | 102 | } |
| 95 | 103 | |
| 96 | 104 | $invalidCoupons = []; |
| 97 | 105 | |
| @@ -110,16 +118,29 @@ | ||
| 110 | 118 | } |
| 111 | 119 | } |
| 112 | 120 | |
| 113 | 121 | if (empty($validCoupons)) { |
| 114 | - return new \WP_Error('no_valid_coupons', __('Coupon can not be applied.', 'fluent-cart'), $invalidCoupons); | |
| 122 | + $message = __('Coupon can not be applied.', 'fluent-cart'); | |
| 123 | + if (!empty($invalidCoupons)) { | |
| 124 | + $firstInvalid = reset($invalidCoupons); | |
| 125 | + if (!empty($firstInvalid['error'])) { | |
| 126 | + $message = $firstInvalid['error']; | |
| 127 | + } | |
| 128 | + } | |
| 129 | + return new \WP_Error('no_valid_coupons', $message, $invalidCoupons); | |
| 115 | 130 | } |
| 116 | 131 | |
| 117 | - // 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. | |
| 118 | 138 | if (count($validCoupons) >= 2) { |
| 119 | - $intermediateValidCoupons = []; | |
| 120 | - foreach ($validCoupons as $coupon) { | |
| 121 | - 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') { | |
| 122 | 143 | $intermediateValidCoupons[] = $coupon; |
| 123 | 144 | } else { |
| 124 | 145 | $invalidCoupons[$coupon->code] = [ |
| 125 | 146 | 'success' => false, |
| @@ -128,13 +149,9 @@ | ||
| 128 | 149 | ]; |
| 129 | 150 | } |
| 130 | 151 | } |
| 131 | 152 | |
| 132 | - if (!$intermediateValidCoupons) { | |
| 133 | - $validCoupons = [$validCoupons[0]]; | |
| 134 | - } else { | |
| 135 | - $validCoupons = $intermediateValidCoupons; | |
| 136 | - } | |
| 153 | + $validCoupons = $intermediateValidCoupons; | |
| 137 | 154 | } |
| 138 | 155 | |
| 139 | 156 | // Ensure stackable coupons are applied in priority order (lower value = higher priority) |
| 140 | 157 | if (count($validCoupons) >= 2) { |
| @@ -226,13 +243,23 @@ | ||
| 226 | 243 | $currentItemsDiscountTotal = $this->calculateExistingCouponDiscount($preValidatedItems); |
| 227 | 244 | $currentItemsTotalAfterDiscount = $currentItemsSubtotal - $currentItemsDiscountTotal; |
| 228 | 245 | |
| 229 | 246 | if ($currentItemsTotalAfterDiscount <= 0) { |
| 230 | - return new \WP_Error('no_applicable_items', __('No applicable items found for this coupon.', 'fluent-cart')); | |
| 247 | + return new \WP_Error('items_already_discounted', __('The eligible items are already fully discounted by another coupon.', 'fluent-cart')); | |
| 231 | 248 | } |
| 232 | 249 | |
| 233 | 250 | $percent = $this->calculateDiscountPercent($coupon, $currentItemsTotalAfterDiscount); |
| 234 | 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 | + | |
| 235 | 262 | list($preValidatedItems, $couponDiscountTotal) = $this->applyDiscountToItems($preValidatedItems, $percent, $coupon); |
| 236 | 263 | |
| 237 | 264 | if ($coupon->type === 'fixed') { |
| 238 | 265 | list($preValidatedItems, $couponDiscountTotal) = $this->correctFixedCouponRounding( |
| @@ -239,12 +266,24 @@ | ||
| 239 | 266 | $preValidatedItems, $coupon, $couponDiscountTotal |
| 240 | 267 | ); |
| 241 | 268 | } |
| 242 | 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 | + | |
| 243 | 282 | $cartItems = $this->mergeValidatedItems($cartItems, $preValidatedItems); |
| 244 | 283 | |
| 245 | 284 | if (!$couponDiscountTotal) { |
| 246 | - return new \WP_Error('no_discount_applied', __('This coupon could not apply any discount.', 'fluent-cart')); | |
| 285 | + return new \WP_Error('no_discount_applied', __('This coupon does not provide any additional discount on your order.', 'fluent-cart')); | |
| 247 | 286 | } |
| 248 | 287 | |
| 249 | 288 | $cartItems = $this->updateItemTotals($cartItems); |
| 250 | 289 | |
| @@ -263,9 +302,9 @@ | ||
| 263 | 302 | 'cart_items' => $cartItems, |
| 264 | 303 | ]); |
| 265 | 304 | |
| 266 | 305 | if (!$canUse || is_wp_error($canUse)) { |
| 267 | - $message = __('This coupon cannot be used.', 'fluent-cart'); | |
| 306 | + $message = __('This coupon is not available for your order.', 'fluent-cart'); | |
| 268 | 307 | if (is_wp_error($canUse)) { |
| 269 | 308 | $message = $canUse->get_error_message(); |
| 270 | 309 | } |
| 271 | 310 | return new \WP_Error('coupon_cannot_be_used', $message); |
| @@ -424,8 +463,67 @@ | ||
| 424 | 463 | |
| 425 | 464 | return [$items, $couponDiscountTotal]; |
| 426 | 465 | } |
| 427 | 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 | + | |
| 428 | 526 | private function correctFixedCouponRounding(array $items, Coupon $coupon, $couponDiscountTotal) |
| 429 | 527 | { |
| 430 | 528 | if ($couponDiscountTotal < $coupon->amount) { |
| 431 | 529 | $remainingAmount = $coupon->amount - $couponDiscountTotal; |
| @@ -493,16 +591,31 @@ | ||
| 493 | 591 | } |
| 494 | 592 | |
| 495 | 593 | private function getItemEffectiveSubtotal(array $item) |
| 496 | 594 | { |
| 497 | - $subtotal = (int) $item['subtotal']; | |
| 498 | 595 | if (Arr::get($item, 'other_info.payment_type') === 'subscription' |
| 499 | 596 | && Arr::get($item, 'other_info.trial_days', 0) > 0 |
| 500 | 597 | ) { |
| 501 | - $quantity = (int) Arr::get($item, 'quantity', 1); | |
| 502 | - $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; | |
| 503 | 605 | } |
| 504 | - 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']; | |
| 505 | 618 | } |
| 506 | 619 | |
| 507 | 620 | public function saveCart() |
| 508 | 621 | { |
| @@ -539,30 +652,51 @@ | ||
| 539 | 652 | } |
| 540 | 653 | |
| 541 | 654 | protected function isCouponValid($coupon) |
| 542 | 655 | { |
| 656 | + $status = $coupon->status; | |
| 657 | + if ($status === 'expired') { | |
| 658 | + return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart')); | |
| 659 | + } | |
| 660 | + if ($status === 'scheduled') { | |
| 661 | + return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart')); | |
| 662 | + } | |
| 663 | + if ($status !== 'active') { | |
| 664 | + return new \WP_Error('coupon_not_available', __('This coupon is not currently available.', 'fluent-cart')); | |
| 665 | + } | |
| 666 | + | |
| 543 | 667 | // let's validate the start date and end date first |
| 544 | 668 | $startDate = $coupon->start_date; |
| 545 | 669 | if ($startDate && $startDate != '0000-00-00 00:00:00' && strtotime($startDate) > time()) { |
| 546 | - return new \WP_Error('coupon_not_started', __('This coupon is no longer valid.', 'fluent-cart')); | |
| 670 | + return new \WP_Error('coupon_not_started', __('This coupon is not yet active.', 'fluent-cart')); | |
| 547 | 671 | } |
| 548 | 672 | $endDate = $coupon->end_date; |
| 549 | 673 | if ($endDate && $endDate != '0000-00-00 00:00:00' && strtotime($endDate) < time()) { |
| 550 | - return new \WP_Error('coupon_expired', __('This coupon is no longer valid.', 'fluent-cart')); | |
| 674 | + return new \WP_Error('coupon_expired', __('This coupon has expired.', 'fluent-cart')); | |
| 551 | 675 | } |
| 552 | 676 | |
| 553 | 677 | $conditions = $coupon->conditions; |
| 554 | 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 | + | |
| 555 | 686 | // add check max_purchase_amount |
| 556 | 687 | $maxPurchaseAmount = Arr::get($conditions, 'max_purchase_amount', 0); |
| 557 | 688 | $getCartTotal = 0; |
| 558 | 689 | if ($this->cart) { |
| 559 | - $getCartTotal = ($this->cart->getEstimatedTotal() / 100); | |
| 690 | + $cartAmount = $amountBasis === 'total' | |
| 691 | + ? $this->cart->getEstimatedTotal() | |
| 692 | + : $this->cart->getItemsSubtotal(); | |
| 693 | + $getCartTotal = ($cartAmount / 100); | |
| 560 | 694 | } |
| 561 | 695 | |
| 562 | 696 | if ($maxPurchaseAmount) { |
| 563 | 697 | if ($getCartTotal > $maxPurchaseAmount) { |
| 564 | - return new \WP_Error('max_purchase_amount_exceeded', __('This coupon is no longer valid.', 'fluent-cart')); | |
| 698 | + return new \WP_Error('max_purchase_amount_exceeded', __('Your cart total exceeds the maximum amount allowed for this coupon.', 'fluent-cart')); | |
| 565 | 699 | } |
| 566 | 700 | } |
| 567 | 701 | |
| 568 | 702 | $minPurchaseAmount = Arr::get($conditions, 'min_purchase_amount', 0); |
| @@ -567,9 +701,9 @@ | ||
| 567 | 701 | |
| 568 | 702 | $minPurchaseAmount = Arr::get($conditions, 'min_purchase_amount', 0); |
| 569 | 703 | if ($minPurchaseAmount) { |
| 570 | 704 | if ($getCartTotal < ($minPurchaseAmount / 100)) { |
| 571 | - return new \WP_Error('min_purchase_amount_not_met', __('This coupon is no longer valid.', 'fluent-cart')); | |
| 705 | + return new \WP_Error('min_purchase_amount_not_met', __('Your cart total is below the minimum required to use this coupon.', 'fluent-cart')); | |
| 572 | 706 | } |
| 573 | 707 | } |
| 574 | 708 | |
| 575 | 709 | // Let's check the use count and max uses |
| @@ -600,9 +734,9 @@ | ||
| 600 | 734 | |
| 601 | 735 | $usedCount = $usageQuery->count(); |
| 602 | 736 | |
| 603 | 737 | if ($usedCount >= $maxPerCustomer) { |
| 604 | - return new \WP_Error('coupon_max_uses_exceeded', __('You have reached the maximum number of uses for this coupon.', 'fluent-cart')); | |
| 738 | + return new \WP_Error('coupon_max_uses_exceeded', __('You have already used this coupon the maximum number of times.', 'fluent-cart')); | |
| 605 | 739 | } |
| 606 | 740 | } |
| 607 | 741 | } |
| 608 | 742 | |