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 | api/Resource/CouponResource.php +191 -15 1.5.1 → 1.7.1 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();
@@ -203,12 +231,13 @@
203 231 $getAppliedCouponLists = array_diff($getAppliedCouponLists, $appliedKeys);
204 232
205 233 if (!empty($getAppliedCouponLists)) {
206 234 $getAppliedCouponLists = Coupon::query()->whereIn('id', $getAppliedCouponLists)->get()->keyBy('code')->toArray();
207 - $previouslyAppliedCouponCodes = $previouslyAppliedCouponCodes->merge($getAppliedCouponLists);
235 + // Union keeps the coupon-code keys: the ORM merge() needs models, and array_merge renumbers numeric codes.
236 + $previouslyAppliedCouponCodes = new Collection($previouslyAppliedCouponCodes->all() + $getAppliedCouponLists);
208 237 }
209 238
210 - $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray());
239 + $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
211 240 $couponApplied = $couponService->applyCoupon($couponCode);
212 241 if (is_wp_error($couponApplied)) {
213 242 return $couponApplied;
214 243 }
@@ -220,11 +249,15 @@
220 249 $errors = $couponService->getCouponErrors();
221 250
222 251
223 252 if($errors->has($couponCode)){
253 + // 422: without the status argument the WP_Error carries no
254 + // data.status and WP_REST_Server answers 500 for what is a
255 + // validation outcome (stacking, expiry, limits, spend gates).
256 + // The update/delete paths already pass their status the same way.
224 257 return static::makeErrorResponse([
225 258 ['code' => 400, 'message' => $errors->get($couponCode)->get_error_message()]
226 - ]);
259 + ], 422);
227 260 }
228 261
229 262 if ($returnCouponService) {
230 263 return [
@@ -242,12 +275,14 @@
242 275 $couponCode = Arr::get($data, 'coupon_code', null);
243 276 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
244 277 $orderId = Arr::get($data, 'order_uuid', null);
245 278 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
279 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
246 280 $previouslyAppliedCouponCodes = new Collection();
247 281
248 282 if (!empty($orderId)) {
249 - $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons']]);
283 + $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons', 'customer']]);
284 + $customerEmail = $order->customer->email ?? $customerEmail;
250 285 if (!empty($couponId)) {
251 286 $order->appliedCoupons()->where('id', $couponId)->delete();
252 287 Coupon::query()->where('code', $couponCode)->decrement('use_count', 1);
253 288 }
@@ -259,9 +294,9 @@
259 294 $previouslyAppliedCouponCodes = Collection::make($getAppliedCouponLists);
260 295 }
261 296
262 297
263 - $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray());
298 + $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
264 299
265 300 $isCancelled = $couponService->cancelCoupon($couponCode);
266 301
267 302 if (is_wp_error($isCancelled)) {
@@ -287,12 +322,14 @@
287 322 {
288 323 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
289 324 $orderId = Arr::get($data, 'order_uuid', null);
290 325 $appliedCouponIds = Arr::get($data, 'applied_coupons', []);
326 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
291 327 $previouslyAppliedCouponCodes = null;
292 328
293 329 if (!empty($orderId)) {
294 - $order = OrderResource::find($orderId, ['with' => 'appliedCoupons']);
330 + $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
331 + $customerEmail = $order->customer->email ?? $customerEmail;
295 332 if (empty($lineItems)) {
296 333 $order->appliedCoupons()->delete();
297 334 } else {
298 335 $previouslyAppliedCouponCodes = $order->appliedCoupons;
@@ -302,12 +339,12 @@
302 339 // When the request carries coupon IDs (current UI state, may include unsaved coupons),
303 340 // load them directly so freshly applied coupons survive item adjustments before save.
304 341 if (!empty($appliedCouponIds)) {
305 342 $couponCodes = Coupon::query()->whereIn('id', $appliedCouponIds)->pluck('code')->toArray();
306 - $couponService = new CouponServiceAdmin($lineItems, null, $couponCodes);
343 + $couponService = new CouponServiceAdmin($lineItems, null, $couponCodes, $customerEmail);
307 344 } else {
308 345 $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
309 - $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes);
346 + $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes, [], $customerEmail);
310 347 }
311 348
312 349 $couponService->reapplyCoupons();
313 350
@@ -322,8 +359,147 @@
322 359 ];
323 360 }
324 361
325 362 return new WP_Error(__('Something went wrong while updating the cart.', 'fluent-cart'));
363 + }
364 +
365 + /**
366 + * Re-run the coupon rules on an admin order payload before it is saved.
367 + * The payload's coupon discounts come from the client, so they are replaced with calculated ones.
368 + *
369 + * @param array $items Order line items.
370 + * @param array $appliedCoupons Applied-coupon map keyed by coupon code.
371 + * @param string $customerEmail
372 + * @return array|WP_Error ['applied_coupons' => array, 'items' => array]
373 + */
374 + public static function validateOrderCoupons(array $items, array $appliedCoupons, $customerEmail = '')
375 + {
376 + $codes = array_map('strval', array_keys(Arr::except($appliedCoupons, ['*'])));
377 +
378 + if (!$codes) {
379 + return [
380 + 'applied_coupons' => $appliedCoupons,
381 + 'items' => $items,
382 + ];
383 + }
384 +
385 + $couponService = new CouponServiceAdmin($items, null, $codes, (string) $customerEmail);
386 + $couponService->reapplyCoupons();
387 +
388 + $discountData = $couponService->getDiscountData();
389 + $error = $couponService->getCouponErrors()->first();
390 +
391 + if (!$error) {
392 + foreach ($codes as $code) {
393 + if (!isset($discountData[$code])) {
394 + $error = new WP_Error('coupon_not_found', __('Coupon Not Found', 'fluent-cart'));
395 + break;
396 + }
397 + }
398 + }
399 +
400 + if ($error) {
401 + return static::makeErrorResponse([
402 + ['code' => 'invalid_coupon', 'message' => $error->get_error_message()]
403 + ], 422);
404 + }
405 +
406 + // Calculated items are keyed by line item id.
407 + $calculatedItems = $couponService->getCalculatedLineItems();
408 + foreach ($items as $index => $item) {
409 + $calculated = Arr::get($calculatedItems, (string) Arr::get($item, 'id', ''));
410 + $items[$index]['discount_total'] = $calculated ? $calculated['discount_total'] : 0;
411 + }
412 +
413 + return [
414 + 'applied_coupons' => $discountData,
415 + 'items' => $items,
416 + ];
417 + }
418 +
419 + /**
420 + * Report whether a product may be bought while the given coupons are applied.
421 + *
422 + * A product is eligible only when every applied coupon accepts it, so the first
423 + * rejection wins and carries its message back to the caller.
424 + *
425 + * @param int $productId
426 + * @param array $appliedCouponCodes Coupon codes, per the published API contract.
427 + * @param string|null $origin Context the check came from; not currently branched on.
428 + * @return array|WP_Error
429 + */
430 + public static function checkProductEligibilityForCodes($productId, $appliedCouponCodes = [], $origin = null)
431 + {
432 + $appliedCouponCodes = (array)$appliedCouponCodes;
433 +
434 + // Rejected rather than truncated: a silently shortened list would answer a question
435 + // the caller did not ask. An order carries a handful of coupons, never this many.
436 + if (count($appliedCouponCodes) > static::MAX_ELIGIBILITY_COUPONS) {
437 + return new WP_Error(
438 + 'too_many_applied_coupons',
439 + sprintf(
440 + /* translators: %d: maximum number of applied coupons accepted per request */
441 + __('Too many applied coupons supplied. Send at most %d.', 'fluent-cart'),
442 + static::MAX_ELIGIBILITY_COUPONS
443 + ),
444 + ['status' => 400]
445 + );
446 + }
447 +
448 + $codes = [];
449 +
450 + foreach ($appliedCouponCodes as $appliedCouponCode) {
451 + if (!is_scalar($appliedCouponCode) || is_bool($appliedCouponCode) || $appliedCouponCode === '') {
452 + continue;
453 + }
454 +
455 + $codes[(string)$appliedCouponCode] = (string)$appliedCouponCode;
456 + }
457 +
458 + if (!$codes) {
459 + return [
460 + 'isApplicable' => true,
461 + ];
462 + }
463 +
464 + $productModel = Product::with('wp_terms')->find($productId);
465 +
466 + if (!$productModel) {
467 + return [
468 + 'isApplicable' => false,
469 + 'message' => __('Product not found.', 'fluent-cart'),
470 + ];
471 + }
472 +
473 + $coupons = static::getQuery()
474 + ->whereIn('code', array_values($codes))
475 + ->get()
476 + ->keyBy('code');
477 +
478 + foreach ($codes as $code) {
479 + // Direct access, not Arr::get — a coupon code may legitimately contain a dot.
480 + $coupon = isset($coupons[$code]) ? $coupons[$code] : null;
481 +
482 + if (!$coupon) {
483 + return [
484 + 'isApplicable' => false,
485 + 'message' => __('This coupon is no longer available.', 'fluent-cart'),
486 + ];
487 + }
488 +
489 + $eligibility = CouponHelper::evaluateProductEligibility($productModel, $coupon);
490 +
491 + if (!Arr::get($eligibility, 'isApplicable')) {
492 + return [
493 + 'isApplicable' => false,
494 + 'message' => Arr::get($eligibility, 'message', ''),
495 + ];
496 + }
497 + }
498 +
499 + return [
500 + 'isApplicable' => true,
501 + ];
326 502 }
327 503
328 504 private static function makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes)
329 505 {