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.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 trunk All 48 releases
← All changes | api/Resource/CouponResource.php +144 -16 1.3.25 → 1.6.5 View file →
@@ -1,10 +1,12 @@
1 1 <?php
2 2
3 3 namespace FluentCart\Api\Resource;
4 4
5 +use FluentCart\App\Helpers\CouponHelper;
5 6 use FluentCart\App\Helpers\Helper;
6 7 use FluentCart\App\Models\Coupon;
8 +use FluentCart\App\Models\Product;
7 9 use FluentCart\App\Services\Coupon\CouponServiceAdmin;
8 10 use FluentCart\App\Services\DateTime\DateTime;
9 11 use FluentCart\Framework\Database\Orm\Builder;
10 12 use FluentCart\Framework\Support\Arr;
@@ -17,8 +19,12 @@
17 19 * @package FluentCart\Api\Resource
18 20 */
19 21 class CouponResource extends BaseResourceApi
20 22 {
23 + /**
24 + * Upper bound on the applied-coupon list a single eligibility check will accept.
25 + */
26 + const MAX_ELIGIBILITY_COUPONS = 50;
21 27
22 28 /**
23 29 * Get the query builder instance for the Coupons model.
24 30 *
@@ -81,8 +87,17 @@
81 87
82 88
83 89 public static function create($data, $params = [])
84 90 {
91 + // New coupons default the spend basis to subtotal (the documented behavior) when a client
92 + // omits it. Only applies on create — there is no prior behavior to preserve for a new row.
93 + if (!is_array(Arr::get($data, 'conditions'))) {
94 + $data['conditions'] = [];
95 + }
96 + if (empty($data['conditions']['min_amount_basis'])) {
97 + $data['conditions']['min_amount_basis'] = 'subtotal';
98 + }
99 +
85 100 $data = self::formatAmount($data);
86 101 $isCreated = static::getQuery()->create($data);
87 102
88 103 if ($isCreated) {
@@ -122,9 +137,9 @@
122 137
123 138 if (!$id) {
124 139 return static::makeErrorResponse([
125 140 ['code' => 403, 'message' => __('Please edit a valid coupon!', 'fluent-cart')]
126 - ]);
141 + ], 403);
127 142 }
128 143
129 144 $hasCoupon = self::find($id);
130 145
@@ -130,11 +145,22 @@
130 145
131 146 if (!$hasCoupon) {
132 147 return static::makeErrorResponse([
133 148 ['code' => 404, 'message' => __('Coupon not found, please reload the page and try again!', 'fluent-cart')]
134 - ]);
149 + ], 404);
135 150 }
136 151
152 + // If the client sends conditions without a valid spend basis (e.g. an older REST client that
153 + // predates the field), keep whatever is already stored so an unrelated edit never silently
154 + // flips the coupon's spend basis. Legacy coupons with no stored basis stay absent and fall
155 + // back to 'total' at validation.
156 + if (is_array(Arr::get($data, 'conditions')) && empty($data['conditions']['min_amount_basis'])) {
157 + $existingBasis = Arr::get($hasCoupon->conditions, 'min_amount_basis');
158 + if (!empty($existingBasis)) {
159 + $data['conditions']['min_amount_basis'] = $existingBasis;
160 + }
161 + }
162 +
137 163 $data = self::formatAmount($data);
138 164
139 165 $isUpdated = $hasCoupon->update($data);
140 166
@@ -143,9 +169,9 @@
143 169 }
144 170
145 171 return static::makeErrorResponse([
146 172 ['code' => 400, 'message' => __('Coupon update failed.', 'fluent-cart')]
147 - ]);
173 + ], 400);
148 174 }
149 175
150 176 public static function delete($id, $params = [])
151 177 {
@@ -151,9 +177,9 @@
151 177 {
152 178 if (!$id) {
153 179 return static::makeErrorResponse([
154 180 ['code' => 403, 'message' => __('Please use a valid coupon ID!', 'fluent-cart')]
155 - ]);
181 + ], 403);
156 182 }
157 183
158 184 $hasCoupon = self::find($id);
159 185
@@ -163,14 +189,14 @@
163 189 }
164 190
165 191 return static::makeErrorResponse([
166 192 ['code' => 400, 'message' => __('Coupon deletion failed!', 'fluent-cart')]
167 - ]);
193 + ], 400);
168 194 }
169 195
170 196 return static::makeErrorResponse([
171 197 ['code' => 404, 'message' => __('Coupon not found in database, failed to remove.', 'fluent-cart')]
172 - ]);
198 + ], 404);
173 199 }
174 200
175 201 private static function formatAmount($data)
176 202 {
@@ -192,11 +218,13 @@
192 218 $couponCode = Arr::get($data, 'coupon_code');
193 219 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
194 220 $orderId = Arr::get($data, 'order_uuid', null);
195 221 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
222 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
196 223 $previouslyAppliedCouponCodes = new Collection();
197 224 if (!empty($orderId)) {
198 - $order = OrderResource::find($orderId, ['with' => 'appliedCoupons']);
225 + $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
226 + $customerEmail = $order->customer->email ?? $customerEmail;
199 227 $previouslyAppliedCouponCodes = $order->appliedCoupons;
200 228 $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
201 229 }
202 230 $appliedKeys = $previouslyAppliedCouponCodes->pluck('id')->toArray();
@@ -206,9 +234,9 @@
206 234 $getAppliedCouponLists = Coupon::query()->whereIn('id', $getAppliedCouponLists)->get()->keyBy('code')->toArray();
207 235 $previouslyAppliedCouponCodes = $previouslyAppliedCouponCodes->merge($getAppliedCouponLists);
208 236 }
209 237
210 - $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray());
238 + $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
211 239 $couponApplied = $couponService->applyCoupon($couponCode);
212 240 if (is_wp_error($couponApplied)) {
213 241 return $couponApplied;
214 242 }
@@ -220,11 +248,15 @@
220 248 $errors = $couponService->getCouponErrors();
221 249
222 250
223 251 if($errors->has($couponCode)){
252 + // 422: without the status argument the WP_Error carries no
253 + // data.status and WP_REST_Server answers 500 for what is a
254 + // validation outcome (stacking, expiry, limits, spend gates).
255 + // The update/delete paths already pass their status the same way.
224 256 return static::makeErrorResponse([
225 257 ['code' => 400, 'message' => $errors->get($couponCode)->get_error_message()]
226 - ]);
258 + ], 422);
227 259 }
228 260
229 261 if ($returnCouponService) {
230 262 return [
@@ -242,12 +274,14 @@
242 274 $couponCode = Arr::get($data, 'coupon_code', null);
243 275 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
244 276 $orderId = Arr::get($data, 'order_uuid', null);
245 277 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
246 - $previouslyAppliedCouponCodes = null;
278 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
279 + $previouslyAppliedCouponCodes = new Collection();
247 280
248 281 if (!empty($orderId)) {
249 - $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons']]);
282 + $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons', 'customer']]);
283 + $customerEmail = $order->customer->email ?? $customerEmail;
250 284 if (!empty($couponId)) {
251 285 $order->appliedCoupons()->where('id', $couponId)->delete();
252 286 Coupon::query()->where('code', $couponCode)->decrement('use_count', 1);
253 287 }
@@ -259,9 +293,9 @@
259 293 $previouslyAppliedCouponCodes = Collection::make($getAppliedCouponLists);
260 294 }
261 295
262 296
263 - $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray());
297 + $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
264 298
265 299 $isCancelled = $couponService->cancelCoupon($couponCode);
266 300
267 301 if (is_wp_error($isCancelled)) {
@@ -286,13 +320,15 @@
286 320 public static function reapplyCoupon($data, $returnCouponService = true)
287 321 {
288 322 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
289 323 $orderId = Arr::get($data, 'order_uuid', null);
324 + $appliedCouponIds = Arr::get($data, 'applied_coupons', []);
325 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
290 326 $previouslyAppliedCouponCodes = null;
291 327
292 -
293 328 if (!empty($orderId)) {
294 - $order = OrderResource::find($orderId, ['with' => 'appliedCoupons']);
329 + $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
330 + $customerEmail = $order->customer->email ?? $customerEmail;
295 331 if (empty($lineItems)) {
296 332 $order->appliedCoupons()->delete();
297 333 } else {
298 334 $previouslyAppliedCouponCodes = $order->appliedCoupons;
@@ -298,11 +334,18 @@
298 334 $previouslyAppliedCouponCodes = $order->appliedCoupons;
299 335 }
300 336 }
301 337
302 - $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
338 + // When the request carries coupon IDs (current UI state, may include unsaved coupons),
339 + // load them directly so freshly applied coupons survive item adjustments before save.
340 + if (!empty($appliedCouponIds)) {
341 + $couponCodes = Coupon::query()->whereIn('id', $appliedCouponIds)->pluck('code')->toArray();
342 + $couponService = new CouponServiceAdmin($lineItems, null, $couponCodes, $customerEmail);
343 + } else {
344 + $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
345 + $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes, [], $customerEmail);
346 + }
303 347
304 - $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes);
305 348 $couponService->reapplyCoupons();
306 349
307 350 $calculated_items = $couponService->getCalculatedLineItems();
308 351
@@ -315,8 +358,93 @@
315 358 ];
316 359 }
317 360
318 361 return new WP_Error(__('Something went wrong while updating the cart.', 'fluent-cart'));
362 + }
363 +
364 + /**
365 + * Report whether a product may be bought while the given coupons are applied.
366 + *
367 + * A product is eligible only when every applied coupon accepts it, so the first
368 + * rejection wins and carries its message back to the caller.
369 + *
370 + * @param int $productId
371 + * @param array $appliedCouponCodes Coupon codes, per the published API contract.
372 + * @param string|null $origin Context the check came from; not currently branched on.
373 + * @return array|WP_Error
374 + */
375 + public static function checkProductEligibilityForCodes($productId, $appliedCouponCodes = [], $origin = null)
376 + {
377 + $appliedCouponCodes = (array)$appliedCouponCodes;
378 +
379 + // Rejected rather than truncated: a silently shortened list would answer a question
380 + // the caller did not ask. An order carries a handful of coupons, never this many.
381 + if (count($appliedCouponCodes) > static::MAX_ELIGIBILITY_COUPONS) {
382 + return new WP_Error(
383 + 'too_many_applied_coupons',
384 + sprintf(
385 + /* translators: %d: maximum number of applied coupons accepted per request */
386 + __('Too many applied coupons supplied. Send at most %d.', 'fluent-cart'),
387 + static::MAX_ELIGIBILITY_COUPONS
388 + ),
389 + ['status' => 400]
390 + );
391 + }
392 +
393 + $codes = [];
394 +
395 + foreach ($appliedCouponCodes as $appliedCouponCode) {
396 + if (!is_scalar($appliedCouponCode) || is_bool($appliedCouponCode) || $appliedCouponCode === '') {
397 + continue;
398 + }
399 +
400 + $codes[(string)$appliedCouponCode] = (string)$appliedCouponCode;
401 + }
402 +
403 + if (!$codes) {
404 + return [
405 + 'isApplicable' => true,
406 + ];
407 + }
408 +
409 + $productModel = Product::with('wp_terms')->find($productId);
410 +
411 + if (!$productModel) {
412 + return [
413 + 'isApplicable' => false,
414 + 'message' => __('Product not found.', 'fluent-cart'),
415 + ];
416 + }
417 +
418 + $coupons = static::getQuery()
419 + ->whereIn('code', array_values($codes))
420 + ->get()
421 + ->keyBy('code');
422 +
423 + foreach ($codes as $code) {
424 + // Direct access, not Arr::get — a coupon code may legitimately contain a dot.
425 + $coupon = isset($coupons[$code]) ? $coupons[$code] : null;
426 +
427 + if (!$coupon) {
428 + return [
429 + 'isApplicable' => false,
430 + 'message' => __('This coupon is no longer available.', 'fluent-cart'),
431 + ];
432 + }
433 +
434 + $eligibility = CouponHelper::evaluateProductEligibility($productModel, $coupon);
435 +
436 + if (!Arr::get($eligibility, 'isApplicable')) {
437 + return [
438 + 'isApplicable' => false,
439 + 'message' => Arr::get($eligibility, 'message', ''),
440 + ];
441 + }
442 + }
443 +
444 + return [
445 + 'isApplicable' => true,
446 + ];
319 447 }
320 448
321 449 private static function makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes)
322 450 {