← All changes
|
app/Services/Coupon/Concerns/CanValidateCoupon.php
+74
-5
1.3.21
→
1.7.1
View file →
| @@ -76,8 +76,16 @@ | ||
| 76 | 76 | if (!$this->ensureMinimumPurchaseAmount($coupon)) { |
| 77 | 77 | return $this->makeError(__('Purchase amount is smaller than required amount', 'fluent-cart'), 403); |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | + if (!$this->ensureMaximumPurchaseAmount($coupon)) { | |
| 81 | + return $this->makeError(__('Your cart total exceeds the maximum amount allowed for this coupon.', 'fluent-cart'), 403); | |
| 82 | + } | |
| 83 | + | |
| 84 | + if (!$this->matchesEmailRestrictions($coupon)) { | |
| 85 | + return $this->makeError(__('This coupon is restricted to specific email addresses', 'fluent-cart'), 403); | |
| 86 | + } | |
| 87 | + | |
| 80 | 88 | if (!$this->isProductValidated($coupon)) { |
| 81 | 89 | return $this->makeError(__('No applicable products in the cart for this coupon', 'fluent-cart'), 403); |
| 82 | 90 | } |
| 83 | 91 | |
| @@ -121,13 +129,63 @@ | ||
| 121 | 129 | $min_purchase_amount = Arr::get($coupon->conditions, 'min_purchase_amount', null); |
| 122 | 130 | if (empty($min_purchase_amount)) { |
| 123 | 131 | return true; |
| 124 | 132 | } else { |
| 133 | + // The coupon's min_amount_basis ('subtotal' | 'total') governs whether shipping counts | |
| 134 | + // toward this gate. This admin order-editing path operates on line items only and has no | |
| 135 | + // live shipping context, so the check is always against the items subtotal here — the | |
| 136 | + // 'total' (shipping-inclusive) basis takes effect on the storefront checkout path | |
| 137 | + // (see DiscountService::isCouponValid). | |
| 125 | 138 | $orderTotal = OrderService::getItemsAmountTotal($this->lineItems, false, false); |
| 126 | 139 | return $min_purchase_amount <= $orderTotal; |
| 127 | 140 | } |
| 128 | 141 | } |
| 129 | 142 | |
| 143 | + public function ensureMaximumPurchaseAmount(Coupon $coupon): bool | |
| 144 | + { | |
| 145 | + $maxPurchaseAmount = Arr::get($coupon->conditions, 'max_purchase_amount', null); | |
| 146 | + if (empty($maxPurchaseAmount)) { | |
| 147 | + return true; | |
| 148 | + } | |
| 149 | + | |
| 150 | + // Unlike min_purchase_amount (converted to cents by CouponResource::formatCouponData), | |
| 151 | + // max_purchase_amount is stored as the decimal amount the admin typed — so the | |
| 152 | + // cents-denominated items total must come down to decimal before comparing, exactly | |
| 153 | + // as DiscountService::isCouponValid does on the storefront path. | |
| 154 | + $orderTotal = OrderService::getItemsAmountTotal($this->lineItems, false, false); | |
| 155 | + | |
| 156 | + return ($orderTotal / 100) <= $maxPurchaseAmount; | |
| 157 | + } | |
| 158 | + | |
| 159 | + public function matchesEmailRestrictions(Coupon $coupon): bool | |
| 160 | + { | |
| 161 | + $emailRestrictions = trim((string) Arr::get($coupon->conditions, 'email_restrictions', '')); | |
| 162 | + if (!$emailRestrictions) { | |
| 163 | + return true; | |
| 164 | + } | |
| 165 | + | |
| 166 | + $allowedEmails = array_filter(array_map('trim', explode(',', $emailRestrictions))); | |
| 167 | + if (!$allowedEmails) { | |
| 168 | + return true; | |
| 169 | + } | |
| 170 | + | |
| 171 | + // A restricted coupon needs a known customer email to match against — | |
| 172 | + // same contract as the storefront path (DiscountService), which also | |
| 173 | + // refuses when the cart has no email yet. | |
| 174 | + if (!$this->customerEmail) { | |
| 175 | + return false; | |
| 176 | + } | |
| 177 | + | |
| 178 | + foreach ($allowedEmails as $email) { | |
| 179 | + $pattern = '/^' . str_replace('\*', '.*', preg_quote($email, '/')) . '$/i'; | |
| 180 | + if (preg_match($pattern, $this->customerEmail)) { | |
| 181 | + return true; | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + return false; | |
| 186 | + } | |
| 187 | + | |
| 130 | 188 | public function isCouponActive(Coupon $coupon): bool |
| 131 | 189 | { |
| 132 | 190 | $status = $coupon->status; |
| 133 | 191 | $now = DateTime::gmtNow(); |
| @@ -213,14 +271,25 @@ | ||
| 213 | 271 | if (empty($customer)) { |
| 214 | 272 | return true; |
| 215 | 273 | } |
| 216 | 274 | |
| 217 | - $appliedCoupons = AppliedCoupon::query() | |
| 218 | - ->where('code', $coupon->code) | |
| 219 | - ->where('customer_id', $customer->id) | |
| 220 | - ->get(); | |
| 275 | + // fct_applied_coupons has no customer_id column — the customer is | |
| 276 | + // reachable only through the order the coupon row belongs to. | |
| 277 | + // coupon_id is nullable: legacy rows carry only the code, and skipping | |
| 278 | + // them would let a capped customer reuse the coupon. | |
| 279 | + $usedCount = AppliedCoupon::query() | |
| 280 | + ->where(function ($query) use ($coupon) { | |
| 281 | + $query->where('coupon_id', $coupon->id) | |
| 282 | + ->orWhere(function ($legacyQuery) use ($coupon) { | |
| 283 | + $legacyQuery->whereNull('coupon_id')->where('code', $coupon->code); | |
| 284 | + }); | |
| 285 | + }) | |
| 286 | + ->whereHas('order', function ($orderQuery) use ($customer) { | |
| 287 | + $orderQuery->where('customer_id', $customer->id); | |
| 288 | + }) | |
| 289 | + ->count(); | |
| 221 | 290 | |
| 222 | - return $maxPerCustomer > $appliedCoupons->count(); | |
| 291 | + return $maxPerCustomer > $usedCount; | |
| 223 | 292 | } |
| 224 | 293 | |
| 225 | 294 | public function isApplicableToProduct(Coupon $coupon, $productId, $variationsId): bool |
| 226 | 295 | { |