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 +171 -15 1.6.0 → 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 *
@@ -131,9 +137,9 @@
131 137
132 138 if (!$id) {
133 139 return static::makeErrorResponse([
134 140 ['code' => 403, 'message' => __('Please edit a valid coupon!', 'fluent-cart')]
135 - ]);
141 + ], 403);
136 142 }
137 143
138 144 $hasCoupon = self::find($id);
139 145
@@ -139,9 +145,9 @@
139 145
140 146 if (!$hasCoupon) {
141 147 return static::makeErrorResponse([
142 148 ['code' => 404, 'message' => __('Coupon not found, please reload the page and try again!', 'fluent-cart')]
143 - ]);
149 + ], 404);
144 150 }
145 151
146 152 // If the client sends conditions without a valid spend basis (e.g. an older REST client that
147 153 // predates the field), keep whatever is already stored so an unrelated edit never silently
@@ -163,9 +169,9 @@
163 169 }
164 170
165 171 return static::makeErrorResponse([
166 172 ['code' => 400, 'message' => __('Coupon update failed.', 'fluent-cart')]
167 - ]);
173 + ], 400);
168 174 }
169 175
170 176 public static function delete($id, $params = [])
171 177 {
@@ -171,9 +177,9 @@
171 177 {
172 178 if (!$id) {
173 179 return static::makeErrorResponse([
174 180 ['code' => 403, 'message' => __('Please use a valid coupon ID!', 'fluent-cart')]
175 - ]);
181 + ], 403);
176 182 }
177 183
178 184 $hasCoupon = self::find($id);
179 185
@@ -183,14 +189,14 @@
183 189 }
184 190
185 191 return static::makeErrorResponse([
186 192 ['code' => 400, 'message' => __('Coupon deletion failed!', 'fluent-cart')]
187 - ]);
193 + ], 400);
188 194 }
189 195
190 196 return static::makeErrorResponse([
191 197 ['code' => 404, 'message' => __('Coupon not found in database, failed to remove.', 'fluent-cart')]
192 - ]);
198 + ], 404);
193 199 }
194 200
195 201 private static function formatAmount($data)
196 202 {
@@ -212,11 +218,13 @@
212 218 $couponCode = Arr::get($data, 'coupon_code');
213 219 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
214 220 $orderId = Arr::get($data, 'order_uuid', null);
215 221 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
222 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
216 223 $previouslyAppliedCouponCodes = new Collection();
217 224 if (!empty($orderId)) {
218 - $order = OrderResource::find($orderId, ['with' => 'appliedCoupons']);
225 + $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
226 + $customerEmail = $order->customer->email ?? $customerEmail;
219 227 $previouslyAppliedCouponCodes = $order->appliedCoupons;
220 228 $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
221 229 }
222 230 $appliedKeys = $previouslyAppliedCouponCodes->pluck('id')->toArray();
@@ -223,12 +231,13 @@
223 231 $getAppliedCouponLists = array_diff($getAppliedCouponLists, $appliedKeys);
224 232
225 233 if (!empty($getAppliedCouponLists)) {
226 234 $getAppliedCouponLists = Coupon::query()->whereIn('id', $getAppliedCouponLists)->get()->keyBy('code')->toArray();
227 - $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);
228 237 }
229 238
230 - $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray());
239 + $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
231 240 $couponApplied = $couponService->applyCoupon($couponCode);
232 241 if (is_wp_error($couponApplied)) {
233 242 return $couponApplied;
234 243 }
@@ -240,11 +249,15 @@
240 249 $errors = $couponService->getCouponErrors();
241 250
242 251
243 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.
244 257 return static::makeErrorResponse([
245 258 ['code' => 400, 'message' => $errors->get($couponCode)->get_error_message()]
246 - ]);
259 + ], 422);
247 260 }
248 261
249 262 if ($returnCouponService) {
250 263 return [
@@ -262,12 +275,14 @@
262 275 $couponCode = Arr::get($data, 'coupon_code', null);
263 276 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
264 277 $orderId = Arr::get($data, 'order_uuid', null);
265 278 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
279 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
266 280 $previouslyAppliedCouponCodes = new Collection();
267 281
268 282 if (!empty($orderId)) {
269 - $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons']]);
283 + $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons', 'customer']]);
284 + $customerEmail = $order->customer->email ?? $customerEmail;
270 285 if (!empty($couponId)) {
271 286 $order->appliedCoupons()->where('id', $couponId)->delete();
272 287 Coupon::query()->where('code', $couponCode)->decrement('use_count', 1);
273 288 }
@@ -279,9 +294,9 @@
279 294 $previouslyAppliedCouponCodes = Collection::make($getAppliedCouponLists);
280 295 }
281 296
282 297
283 - $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray());
298 + $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
284 299
285 300 $isCancelled = $couponService->cancelCoupon($couponCode);
286 301
287 302 if (is_wp_error($isCancelled)) {
@@ -307,12 +322,14 @@
307 322 {
308 323 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
309 324 $orderId = Arr::get($data, 'order_uuid', null);
310 325 $appliedCouponIds = Arr::get($data, 'applied_coupons', []);
326 + $customerEmail = (string) Arr::get($data, 'customer_email', '');
311 327 $previouslyAppliedCouponCodes = null;
312 328
313 329 if (!empty($orderId)) {
314 - $order = OrderResource::find($orderId, ['with' => 'appliedCoupons']);
330 + $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
331 + $customerEmail = $order->customer->email ?? $customerEmail;
315 332 if (empty($lineItems)) {
316 333 $order->appliedCoupons()->delete();
317 334 } else {
318 335 $previouslyAppliedCouponCodes = $order->appliedCoupons;
@@ -322,12 +339,12 @@
322 339 // When the request carries coupon IDs (current UI state, may include unsaved coupons),
323 340 // load them directly so freshly applied coupons survive item adjustments before save.
324 341 if (!empty($appliedCouponIds)) {
325 342 $couponCodes = Coupon::query()->whereIn('id', $appliedCouponIds)->pluck('code')->toArray();
326 - $couponService = new CouponServiceAdmin($lineItems, null, $couponCodes);
343 + $couponService = new CouponServiceAdmin($lineItems, null, $couponCodes, $customerEmail);
327 344 } else {
328 345 $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
329 - $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes);
346 + $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes, [], $customerEmail);
330 347 }
331 348
332 349 $couponService->reapplyCoupons();
333 350
@@ -342,8 +359,147 @@
342 359 ];
343 360 }
344 361
345 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 + ];
346 502 }
347 503
348 504 private static function makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes)
349 505 {