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
fluent-cart / api / Resource / CouponResource.php

CouponResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.1, at api/Resource/CouponResource.php

537 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\Api\Resource;
4
5 use FluentCart\App\Helpers\CouponHelper;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Models\Coupon;
8 use FluentCart\App\Models\Product;
9 use FluentCart\App\Services\Coupon\CouponServiceAdmin;
10 use FluentCart\App\Services\DateTime\DateTime;
11 use FluentCart\Framework\Database\Orm\Builder;
12 use FluentCart\Framework\Support\Arr;
13 use FluentCart\Framework\Support\Collection;
14 use WP_Error;
15
16 /**
17 * Class CouponResource
18 *
19 * @package FluentCart\Api\Resource
20 */
21 class CouponResource extends BaseResourceApi
22 {
23 /**
24 * Upper bound on the applied-coupon list a single eligibility check will accept.
25 */
26 const MAX_ELIGIBILITY_COUPONS = 50;
27
28 /**
29 * Get the query builder instance for the Coupons model.
30 *
31 * @return Builder
32 */
33
34 public static function getQuery(): Builder
35 {
36 return Coupon::query();
37 }
38
39 public static function get(array $params = [])
40 {
41 $coupons = static::getQuery()
42 ->withCount([
43 'appliedCoupons as total_items' => function ($query) {
44 $query->selectRaw('count(*)');
45 }
46 ])
47 ->orderBy(
48 sanitize_sql_orderby(Arr::get($params, 'order_by', 'id')),
49 sanitize_sql_orderby(Arr::get($params, 'order_type', 'DESC'))
50 )
51 ->paginate(Arr::get($params, 'per_page', 15), ['*'], 'page', Arr::get($params, 'page'));
52
53 return [
54 'data' => $coupons,
55 ];
56 }
57
58 public static function find($id, $params = [])
59 {
60 $type = Arr::get($params, 'type', '');
61
62 if (!empty($type) && $type === 'byCode') {
63 $code = Arr::get($params, 'code', null);
64 $coupon = static::getQuery()->whereRaw("BINARY `code` = ?", [$code])->first();
65 return $coupon;
66 }
67
68 return static::getQuery()->find($id);
69 }
70
71 public static function viewDetails($id)
72 {
73 $coupon = static::getQuery()->with(['activities.user'])->find($id);
74
75 if (!empty($coupon->end_date) && $coupon->end_date !== '0000-00-00 00:00:00') {
76 $endDate = DateTime::anyTimeToGmt($coupon->end_date);
77 $now = new DateTime();
78
79 if ($endDate < $now && $coupon->getStatus() !== 'expired') {
80 $coupon->setStatus('expired');
81 $coupon->save();
82 }
83 }
84 return $coupon;
85
86 }
87
88
89 public static function create($data, $params = [])
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
100 $data = self::formatAmount($data);
101 $isCreated = static::getQuery()->create($data);
102
103 if ($isCreated) {
104 return static::makeSuccessResponse(
105 $isCreated,
106 __('Coupon created successfully!', 'fluent-cart')
107 );
108 }
109
110 return static::makeErrorResponse([
111 ['code' => 400, 'message' => __('Coupon creation failed.', 'fluent-cart')]
112 ]);
113 }
114
115 public static function listCoupon()
116 {
117 return static::getQuery()
118 ->where('status', 'active')
119 ->pluck('code')
120 ->toArray();
121 }
122
123 public static function update($data, $id, $params = [])
124 {
125 if (Arr::get($data, 'max_uses', null) == '') {
126 $data['max_uses'] = null;
127 }
128 if (Arr::get($data, 'max_per_customer', null) == '') {
129 $data['max_per_customer'] = null;
130 }
131 if (Arr::get($data, 'max_discount_amount', null) == '') {
132 $data['max_discount_amount'] = null;
133 }
134 if (Arr::get($data, 'min_purchase_amount', null) == '') {
135 $data['min_purchase_amount'] = null;
136 }
137
138 if (!$id) {
139 return static::makeErrorResponse([
140 ['code' => 403, 'message' => __('Please edit a valid coupon!', 'fluent-cart')]
141 ], 403);
142 }
143
144 $hasCoupon = self::find($id);
145
146 if (!$hasCoupon) {
147 return static::makeErrorResponse([
148 ['code' => 404, 'message' => __('Coupon not found, please reload the page and try again!', 'fluent-cart')]
149 ], 404);
150 }
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
163 $data = self::formatAmount($data);
164
165 $isUpdated = $hasCoupon->update($data);
166
167 if ($isUpdated) {
168 return static::makeSuccessResponse($hasCoupon, __('Coupon updated successfully!', 'fluent-cart'));
169 }
170
171 return static::makeErrorResponse([
172 ['code' => 400, 'message' => __('Coupon update failed.', 'fluent-cart')]
173 ], 400);
174 }
175
176 public static function delete($id, $params = [])
177 {
178 if (!$id) {
179 return static::makeErrorResponse([
180 ['code' => 403, 'message' => __('Please use a valid coupon ID!', 'fluent-cart')]
181 ], 403);
182 }
183
184 $hasCoupon = self::find($id);
185
186 if ($hasCoupon) {
187 if ($hasCoupon->delete()) {
188 return static::makeSuccessResponse('', __('Coupon successfully deleted.', 'fluent-cart'));
189 }
190
191 return static::makeErrorResponse([
192 ['code' => 400, 'message' => __('Coupon deletion failed!', 'fluent-cart')]
193 ], 400);
194 }
195
196 return static::makeErrorResponse([
197 ['code' => 404, 'message' => __('Coupon not found in database, failed to remove.', 'fluent-cart')]
198 ], 404);
199 }
200
201 private static function formatAmount($data)
202 {
203 if (empty(Arr::get($data, 'conditions.max_discount_amount', ''))) {
204 $data['max_discount_amount'] = null;
205 }
206 if (Arr::get($data, 'type') !== 'percentage') {
207 $data['amount'] = Helper::toCent(Arr::get($data, 'amount', 0));
208 }
209 if (!empty(Arr::get($data, 'conditions.max_discount_amount'))) {
210 $data['conditions']['max_discount_amount'] = Helper::toCent($data['conditions']['max_discount_amount']);
211 }
212 $data['conditions']['min_purchase_amount'] = Helper::toCent(Arr::get($data['conditions'], 'min_purchase_amount', 0));
213 return $data;
214 }
215
216 public static function applyCoupon($data, $returnCouponService = true)
217 {
218 $couponCode = Arr::get($data, 'coupon_code');
219 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
220 $orderId = Arr::get($data, 'order_uuid', null);
221 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
222 $customerEmail = (string) Arr::get($data, 'customer_email', '');
223 $previouslyAppliedCouponCodes = new Collection();
224 if (!empty($orderId)) {
225 $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
226 $customerEmail = $order->customer->email ?? $customerEmail;
227 $previouslyAppliedCouponCodes = $order->appliedCoupons;
228 $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
229 }
230 $appliedKeys = $previouslyAppliedCouponCodes->pluck('id')->toArray();
231 $getAppliedCouponLists = array_diff($getAppliedCouponLists, $appliedKeys);
232
233 if (!empty($getAppliedCouponLists)) {
234 $getAppliedCouponLists = Coupon::query()->whereIn('id', $getAppliedCouponLists)->get()->keyBy('code')->toArray();
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);
237 }
238
239 $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
240 $couponApplied = $couponService->applyCoupon($couponCode);
241 if (is_wp_error($couponApplied)) {
242 return $couponApplied;
243 }
244
245 $calculated_items = $couponService->getCalculatedLineItems();
246
247 $applied_coupons = $couponService->getDiscountData();
248
249 $errors = $couponService->getCouponErrors();
250
251
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.
257 return static::makeErrorResponse([
258 ['code' => 400, 'message' => $errors->get($couponCode)->get_error_message()]
259 ], 422);
260 }
261
262 if ($returnCouponService) {
263 return [
264 'applied_coupons' => $applied_coupons,
265 'calculated_items' => $calculated_items,
266 ];
267 }
268
269 return new WP_Error(__('Something went wrong while updating the cart.', 'fluent-cart'));
270 }
271
272 public static function cancelCoupon($data, $returnCouponService = true)
273 {
274 $couponId = Arr::get($data, 'id', null);
275 $couponCode = Arr::get($data, 'coupon_code', null);
276 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
277 $orderId = Arr::get($data, 'order_uuid', null);
278 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
279 $customerEmail = (string) Arr::get($data, 'customer_email', '');
280 $previouslyAppliedCouponCodes = new Collection();
281
282 if (!empty($orderId)) {
283 $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons', 'customer']]);
284 $customerEmail = $order->customer->email ?? $customerEmail;
285 if (!empty($couponId)) {
286 $order->appliedCoupons()->where('id', $couponId)->delete();
287 Coupon::query()->where('code', $couponCode)->decrement('use_count', 1);
288 }
289 }
290
291
292 if (!empty($getAppliedCouponLists)) {
293 $getAppliedCouponLists = Coupon::query()->whereIn('id', $getAppliedCouponLists)->get()->keyBy('code')->toArray();
294 $previouslyAppliedCouponCodes = Collection::make($getAppliedCouponLists);
295 }
296
297
298 $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
299
300 $isCancelled = $couponService->cancelCoupon($couponCode);
301
302 if (is_wp_error($isCancelled)) {
303 return $isCancelled;
304 }
305
306
307 $calculated_items = $couponService->getCalculatedLineItems();
308
309 $applied_coupons = $couponService->getDiscountData();
310
311 if ($returnCouponService) {
312 return [
313 'applied_coupons' => $applied_coupons,
314 'calculated_items' => $calculated_items,
315 ];
316 }
317
318 return new WP_Error(__('Something went wrong while updating the cart.', 'fluent-cart'));
319 }
320
321 public static function reapplyCoupon($data, $returnCouponService = true)
322 {
323 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
324 $orderId = Arr::get($data, 'order_uuid', null);
325 $appliedCouponIds = Arr::get($data, 'applied_coupons', []);
326 $customerEmail = (string) Arr::get($data, 'customer_email', '');
327 $previouslyAppliedCouponCodes = null;
328
329 if (!empty($orderId)) {
330 $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
331 $customerEmail = $order->customer->email ?? $customerEmail;
332 if (empty($lineItems)) {
333 $order->appliedCoupons()->delete();
334 } else {
335 $previouslyAppliedCouponCodes = $order->appliedCoupons;
336 }
337 }
338
339 // When the request carries coupon IDs (current UI state, may include unsaved coupons),
340 // load them directly so freshly applied coupons survive item adjustments before save.
341 if (!empty($appliedCouponIds)) {
342 $couponCodes = Coupon::query()->whereIn('id', $appliedCouponIds)->pluck('code')->toArray();
343 $couponService = new CouponServiceAdmin($lineItems, null, $couponCodes, $customerEmail);
344 } else {
345 $previouslyAppliedCouponCodes = static::makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes);
346 $couponService = new CouponServiceAdmin($lineItems, $previouslyAppliedCouponCodes, [], $customerEmail);
347 }
348
349 $couponService->reapplyCoupons();
350
351 $calculated_items = $couponService->getCalculatedLineItems();
352
353 $applied_coupons = $couponService->getDiscountData();
354
355 if ($returnCouponService) {
356 return [
357 'applied_coupons' => $applied_coupons,
358 'calculated_items' => $calculated_items,
359 ];
360 }
361
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 ];
502 }
503
504 private static function makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes)
505 {
506 if (empty($previouslyAppliedCouponCodes)) {
507 return new Collection();
508 }
509
510 return $previouslyAppliedCouponCodes->mapWithKeys(function ($coupon) {
511 return [
512 $coupon['code'] => [
513 'id' => Arr::get($coupon, 'coupon_id', ''),
514 'title' => $coupon['title'],
515 'priority' => $coupon['priority'] ?? 1,
516 'code' => $coupon['code'],
517 'amount' => $coupon['amount'],
518 'type' => 'fixed',
519 'stackable' => 'yes',
520 'conditions' => [
521 'min_purchase_amount' => 0,
522 'max_purchase_amount' => 0,
523 'included_products' => [],
524 'excluded_products' => [],
525 'included_categories' => [],
526 'excluded_categories' => [],
527 'allowed_user_ids' => [],
528 'allowed_roles' => [],
529 'max_uses' => 0,
530 'max_per_customer' => 0,
531 ]
532 ]
533 ];
534 });
535 }
536 }
537