PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / api / Resource / CouponResource.php

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

482 lines 17.7 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 $previouslyAppliedCouponCodes = $previouslyAppliedCouponCodes->merge($getAppliedCouponLists);
236 }
237
238 $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
239 $couponApplied = $couponService->applyCoupon($couponCode);
240 if (is_wp_error($couponApplied)) {
241 return $couponApplied;
242 }
243
244 $calculated_items = $couponService->getCalculatedLineItems();
245
246 $applied_coupons = $couponService->getDiscountData();
247
248 $errors = $couponService->getCouponErrors();
249
250
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.
256 return static::makeErrorResponse([
257 ['code' => 400, 'message' => $errors->get($couponCode)->get_error_message()]
258 ], 422);
259 }
260
261 if ($returnCouponService) {
262 return [
263 'applied_coupons' => $applied_coupons,
264 'calculated_items' => $calculated_items,
265 ];
266 }
267
268 return new WP_Error(__('Something went wrong while updating the cart.', 'fluent-cart'));
269 }
270
271 public static function cancelCoupon($data, $returnCouponService = true)
272 {
273 $couponId = Arr::get($data, 'id', null);
274 $couponCode = Arr::get($data, 'coupon_code', null);
275 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
276 $orderId = Arr::get($data, 'order_uuid', null);
277 $getAppliedCouponLists = Arr::get($data, 'applied_coupons', []);
278 $customerEmail = (string) Arr::get($data, 'customer_email', '');
279 $previouslyAppliedCouponCodes = new Collection();
280
281 if (!empty($orderId)) {
282 $order = OrderResource::find($orderId, ['with' => ['order_items', 'appliedCoupons', 'customer']]);
283 $customerEmail = $order->customer->email ?? $customerEmail;
284 if (!empty($couponId)) {
285 $order->appliedCoupons()->where('id', $couponId)->delete();
286 Coupon::query()->where('code', $couponCode)->decrement('use_count', 1);
287 }
288 }
289
290
291 if (!empty($getAppliedCouponLists)) {
292 $getAppliedCouponLists = Coupon::query()->whereIn('id', $getAppliedCouponLists)->get()->keyBy('code')->toArray();
293 $previouslyAppliedCouponCodes = Collection::make($getAppliedCouponLists);
294 }
295
296
297 $couponService = new CouponServiceAdmin($lineItems, null, $previouslyAppliedCouponCodes->keys()->toArray(), $customerEmail);
298
299 $isCancelled = $couponService->cancelCoupon($couponCode);
300
301 if (is_wp_error($isCancelled)) {
302 return $isCancelled;
303 }
304
305
306 $calculated_items = $couponService->getCalculatedLineItems();
307
308 $applied_coupons = $couponService->getDiscountData();
309
310 if ($returnCouponService) {
311 return [
312 'applied_coupons' => $applied_coupons,
313 'calculated_items' => $calculated_items,
314 ];
315 }
316
317 return new WP_Error(__('Something went wrong while updating the cart.', 'fluent-cart'));
318 }
319
320 public static function reapplyCoupon($data, $returnCouponService = true)
321 {
322 $lineItems = Arr::except(Arr::get($data, 'order_items', []), ['*']);
323 $orderId = Arr::get($data, 'order_uuid', null);
324 $appliedCouponIds = Arr::get($data, 'applied_coupons', []);
325 $customerEmail = (string) Arr::get($data, 'customer_email', '');
326 $previouslyAppliedCouponCodes = null;
327
328 if (!empty($orderId)) {
329 $order = OrderResource::find($orderId, ['with' => ['appliedCoupons', 'customer']]);
330 $customerEmail = $order->customer->email ?? $customerEmail;
331 if (empty($lineItems)) {
332 $order->appliedCoupons()->delete();
333 } else {
334 $previouslyAppliedCouponCodes = $order->appliedCoupons;
335 }
336 }
337
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 }
347
348 $couponService->reapplyCoupons();
349
350 $calculated_items = $couponService->getCalculatedLineItems();
351
352 $applied_coupons = $couponService->getDiscountData();
353
354 if ($returnCouponService) {
355 return [
356 'applied_coupons' => $applied_coupons,
357 'calculated_items' => $calculated_items,
358 ];
359 }
360
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 ];
447 }
448
449 private static function makeCouponFromAppliedCoupons($previouslyAppliedCouponCodes)
450 {
451 if (empty($previouslyAppliedCouponCodes)) {
452 return new Collection();
453 }
454
455 return $previouslyAppliedCouponCodes->mapWithKeys(function ($coupon) {
456 return [
457 $coupon['code'] => [
458 'id' => Arr::get($coupon, 'coupon_id', ''),
459 'title' => $coupon['title'],
460 'priority' => $coupon['priority'] ?? 1,
461 'code' => $coupon['code'],
462 'amount' => $coupon['amount'],
463 'type' => 'fixed',
464 'stackable' => 'yes',
465 'conditions' => [
466 'min_purchase_amount' => 0,
467 'max_purchase_amount' => 0,
468 'included_products' => [],
469 'excluded_products' => [],
470 'included_categories' => [],
471 'excluded_categories' => [],
472 'allowed_user_ids' => [],
473 'allowed_roles' => [],
474 'max_uses' => 0,
475 'max_per_customer' => 0,
476 ]
477 ]
478 ];
479 });
480 }
481 }
482