PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
← 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 {