PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / discounts.php

discounts.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/discounts.php

1,236 lines 41.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use Exception;
6 use stdClass;
7 use StoreEngine\Classes\Exceptions\StoreEngineException;
8 use StoreEngine\Utils\Formatting;
9 use StoreEngine\Utils\Helper;
10 use StoreEngine\Utils\NumberUtil;
11 use WP_Error;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 class Discounts {
18
19 /**
20 * Reference to cart or order object.
21 *
22 * @var Cart|Order
23 */
24 protected $object;
25
26 /**
27 * Items to discount.
28 *
29 * @var array<int|string,object>
30 */
31 protected array $items = [];
32
33 /**
34 * An array of discounts which have been applied to items.
35 *
36 * @var array[] Code => Item Key => Value
37 */
38 protected array $discounts = [];
39
40 /**
41 * Number of "free"/rewarded units granted per item by a coupon.
42 *
43 * Recorded by add-ons that hand out whole free units rather than a plain
44 * amount off (e.g. the Pro "Advanced Coupons" Buy X Get Y engine). Lets the
45 * UI know which line(s) were rewarded and how many units of that line are
46 * free, so it can be rendered as a separate FREE line.
47 *
48 * @var array[] Code => Item Key => units (int)
49 */
50 protected array $reward_units = [];
51
52 /**
53 * Discounts Constructor.
54 *
55 * @param Cart|Order $object Cart or order object.
56 */
57 public function __construct( $object = null ) {
58 if ( is_a( $object, Cart::class ) ) {
59 $this->set_items_from_cart( $object );
60 } elseif ( is_a( $object, Order::class ) ) {
61 $this->set_items_from_order( $object );
62 }
63 }
64
65 /**
66 * Set items directly. Used by the cart-totals engine.
67 *
68 * @param array<int|string,object> $items Items to set.
69 */
70 public function set_items( array $items ) {
71 $this->items = $items;
72 $this->discounts = [];
73 $this->reward_units = [];
74 uasort( $this->items, [ $this, 'sort_by_price' ] );
75 }
76
77 /**
78 * Normalise cart items which will be discounted.
79 *
80 * @param Cart $cart Cart object.
81 */
82 public function set_items_from_cart( Cart $cart ) {
83 $this->items = [];
84 $this->discounts = [];
85 $this->reward_units = [];
86
87 if ( ! is_a( $cart, Cart::class ) ) {
88 return;
89 }
90
91 $this->object = $cart;
92
93 foreach ( $cart->get_cart_items() as $key => $cart_item ) {
94 $item = new stdClass;
95 $item->key = $key;
96 $item->object = $cart_item;
97 $item->product_id = $cart_item->product_id;
98 $item->product_parent_id = $cart_item->product_parent_id ?? 0;
99 $item->price_id = $cart_item->price_id;
100 $item->price = $cart_item->get_price();
101 $item->quantity = $cart_item->quantity;
102 $item->price = Formatting::add_number_precision_deep( (float) $item->price * (float) $item->quantity );
103 $item->compare_price = Formatting::add_number_precision_deep( $cart_item->compare_price );
104 $this->items[ $key ] = $item;
105 }
106
107 uasort( $this->items, [ $this, 'sort_by_price' ] );
108 }
109
110 /**
111 * Normalize order items which will be discounted.
112 *
113 * @param Order $order Order object.
114 */
115 public function set_items_from_order( Order $order ) {
116 $this->items = [];
117 $this->discounts = [];
118 $this->reward_units = [];
119
120 if ( ! is_a( $order, Order::class ) ) {
121 return;
122 }
123
124 $this->object = $order;
125
126 foreach ( $order->get_line_product_items() as $order_item ) {
127 $item = new stdClass;
128 $item->key = $order_item->get_id();
129 $item->object = $order_item;
130 $item->product_id = $order_item->get_product_id();
131 $item->product_parent_id = null;
132 $item->variation_id = $order_item->get_variation_id();
133 $item->price_id = $order_item->get_price_id();
134 $item->price = Formatting::add_number_precision_deep( $order_item->get_subtotal() );
135 $item->quantity = $order_item->get_quantity();
136 $item->compare_price = null;
137
138 if ( $order->get_prices_include_tax() ) {
139 $item->price += Formatting::add_number_precision_deep( $order_item->get_subtotal_tax() );
140 }
141
142 $this->items[ $order_item->get_id() ] = $item;
143 }
144
145 uasort( $this->items, [ $this, 'sort_by_price' ] );
146 }
147
148 /**
149 * Get the object concerned.
150 *
151 * @return Cart|Order
152 */
153 public function get_object() {
154 return $this->object;
155 }
156
157 /**
158 * Get items.
159 *
160 * @return array<int|string,object>
161 */
162 public function get_items(): array {
163 return $this->items;
164 }
165
166 /**
167 * Get items to validate.
168 *
169 * @return array<int|string,object>
170 */
171 public function get_items_to_validate(): array {
172 return apply_filters( 'storeengine/coupon_get_items_to_validate', $this->get_items(), $this );
173 }
174
175 /**
176 * Get discount by key with or without precision.
177 *
178 * @param string $key name of discount row to return.
179 * @param bool $in_cents Should the totals be returned in cents, or without precision.
180 *
181 * @return float
182 */
183 public function get_discount( string $key, bool $in_cents = false ) {
184 $item_discount_totals = $this->get_discounts_by_item( $in_cents );
185
186 return $item_discount_totals[ $key ] ?? 0;
187 }
188
189 /**
190 * Get all discount totals.
191 *
192 * @param bool $in_cents Should the totals be returned in cents, or without precision.
193 *
194 * @return array
195 */
196 public function get_discounts( bool $in_cents = false ): array {
197 $discounts = $this->discounts;
198
199 return $in_cents ? $discounts : Formatting::remove_number_precision_deep( $discounts );
200 }
201
202 /**
203 * Get all discount totals per item.
204 *
205 * @param bool $in_cents Should the totals be returned in cents, or without precision.
206 *
207 * @return array
208 */
209 public function get_discounts_by_item( bool $in_cents = false ): array {
210 $discounts = $this->discounts;
211 $item_discount_totals = (array) array_shift( $discounts );
212
213 foreach ( $discounts as $item_discounts ) {
214 foreach ( $item_discounts as $item_key => $item_discount ) {
215 $item_discount_totals[ $item_key ] += $item_discount;
216 }
217 }
218
219 return $in_cents ? $item_discount_totals : Formatting::remove_number_precision_deep( $item_discount_totals );
220 }
221
222 /**
223 * Get all discount totals per coupon.
224 *
225 * @param bool $in_cents Should the totals be returned in cents, or without precision.
226 *
227 * @return array
228 */
229 public function get_discounts_by_coupon( bool $in_cents = false ): array {
230 $coupon_discount_totals = array_map( 'array_sum', $this->discounts );
231
232 return $in_cents ? $coupon_discount_totals : Formatting::remove_number_precision_deep( $coupon_discount_totals );
233 }
234
235 /**
236 * Get discounted price of an item without precision.
237 *
238 * @param object $item Get data for this item.
239 *
240 * @return float
241 */
242 public function get_discounted_price( object $item ): float {
243 return Formatting::remove_number_precision_deep( $this->get_discounted_price_in_cents( $item ) );
244 }
245
246 /**
247 * Get discounted price of an item to precision (in cents).
248 *
249 * @param object $item Get data for this item.
250 *
251 * @return int
252 */
253 public function get_discounted_price_in_cents( object $item ): int {
254 return absint( NumberUtil::round( $item->price - $this->get_discount( $item->key, true ) ) );
255 }
256
257 /**
258 * Apply a discount to all items using a coupon.
259 *
260 * @param Coupon $coupon Coupon object being applied to the items.
261 * @param bool $validate Set false to skip coupon validation.
262 *
263 * @return bool|WP_Error True if applied or WP_Error instance in failure.
264 */
265 public function apply_coupon( Coupon $coupon, bool $validate = true ) {
266 if ( ! is_a( $coupon, Coupon::class ) ) {
267 return new WP_Error( 'invalid_coupon', __( 'Invalid coupon', 'storeengine' ) );
268 }
269
270 $is_coupon_valid = $validate ? $this->is_coupon_valid( $coupon ) : true;
271
272 if ( is_wp_error( $is_coupon_valid ) ) {
273 return $is_coupon_valid;
274 }
275
276 $coupon_code = strtolower( $coupon->get_code() );
277 if ( ! isset( $this->discounts[ $coupon_code ] ) || ! is_array( $this->discounts[ $coupon_code ] ) ) {
278 $this->discounts[ $coupon_code ] = array_fill_keys( array_keys( $this->items ), 0 );
279 }
280
281 $items_to_apply = $this->get_items_to_apply_coupon( $coupon );
282
283 /**
284 * Allow add-ons (e.g. StoreEngine Pro "Advanced Coupons") to fully handle
285 * a coupon type that core does not implement — such as Buy X Get Y. Return
286 * true to signal the coupon was handled; core handling is then skipped.
287 *
288 * @param bool $handled Whether the coupon was handled.
289 * @param Coupon $coupon The coupon being applied.
290 * @param array $items_to_apply Items the coupon applies to.
291 * @param Discounts $this The discounts instance (use record_discount()).
292 */
293 if ( true === apply_filters( 'storeengine/discounts/handle_coupon', false, $coupon, $items_to_apply, $this ) ) {
294 return true;
295 }
296
297 $discount_type = $coupon->get_discount_type();
298
299 // Core discounts are handled here as of 3.2.
300 switch ( $discount_type ) {
301 case 'percentage':
302 case 'percent':
303 $this->apply_coupon_percent( $coupon, $items_to_apply );
304 break;
305 case 'fixed_product':
306 $this->apply_coupon_fixed_product( $coupon, $items_to_apply );
307 break;
308 case 'fixedAmount':
309 case 'fixed_cart':
310 $this->apply_coupon_fixed_cart( $coupon, $items_to_apply );
311 break;
312 default:
313 /*
314 * A type core does not implement itself. Add-ons own such types
315 * and claim them through `storeengine/discounts/handle_coupon`
316 * above — when the owning add-on is inactive nothing claims it,
317 * and applying nothing is the only safe outcome (guessing at an
318 * amount would discount carts the merchant never intended).
319 *
320 * Types registered through `storeengine/product_coupon_types`
321 * opt back in to the generic amount-based handler.
322 */
323 if ( in_array( $discount_type, Helper::get_coupon_types(), true ) ) {
324 $this->apply_coupon_custom( $coupon, $items_to_apply );
325 }
326 break;
327 }
328
329 return true;
330 }
331
332 /**
333 * Record a discount amount on an item for a coupon. Used by add-ons that
334 * handle custom coupon types via `storeengine/discounts/handle_coupon`.
335 *
336 * @param string $coupon_code Coupon code.
337 * @param int|string $item_key Item key.
338 * @param int $amount_in_cents Discount amount in cents.
339 */
340 public function record_discount( string $coupon_code, $item_key, int $amount_in_cents ): void {
341 $code = strtolower( $coupon_code );
342 if ( ! isset( $this->discounts[ $code ] ) || ! is_array( $this->discounts[ $code ] ) ) {
343 $this->discounts[ $code ] = array_fill_keys( array_keys( $this->items ), 0 );
344 }
345 if ( ! isset( $this->discounts[ $code ][ $item_key ] ) ) {
346 $this->discounts[ $code ][ $item_key ] = 0;
347 }
348 $this->discounts[ $code ][ $item_key ] += $amount_in_cents;
349 }
350
351 /**
352 * Record rewarded ("free") units on an item for a coupon.
353 *
354 * Drives the FREE-line UI. Used by add-ons whose coupon type grants whole
355 * free units rather than an amount off — record the money separately with
356 * record_discount().
357 *
358 * @param string $coupon_code Coupon code.
359 * @param int|string $item_key Item key.
360 * @param int $units Rewarded units to add.
361 */
362 public function record_reward_units( string $coupon_code, $item_key, int $units = 1 ): void {
363 $code = strtolower( $coupon_code );
364 if ( ! isset( $this->reward_units[ $code ][ $item_key ] ) ) {
365 $this->reward_units[ $code ][ $item_key ] = 0;
366 }
367 $this->reward_units[ $code ][ $item_key ] += $units;
368 }
369
370 /**
371 * @deprecated Use record_reward_units().
372 *
373 * @param string $coupon_code Coupon code.
374 * @param int|string $item_key Item key.
375 * @param int $units Rewarded units to add.
376 */
377 public function record_bogo_reward( string $coupon_code, $item_key, int $units = 1 ): void {
378 $this->record_reward_units( $coupon_code, $item_key, $units );
379 }
380
381 /**
382 * Public accessor for the items a coupon applies to (post product/category
383 * filtering). Used by add-ons handling custom coupon types.
384 *
385 * @param Coupon $coupon Coupon object.
386 *
387 * @return array<int|string,object>
388 */
389 public function get_items_to_apply( Coupon $coupon ): array {
390 return $this->get_items_to_apply_coupon( $coupon );
391 }
392
393 /**
394 * Sort by price.
395 *
396 * @param object $a First element.
397 * @param object $b Second element.
398 *
399 * @return int
400 */
401 protected function sort_by_price( object $a, object $b ): int {
402 $price_1 = $a->price * $a->quantity;
403 $price_2 = $b->price * $b->quantity;
404 if ( $price_1 === $price_2 ) {
405 return 0;
406 }
407
408 return ( $price_1 < $price_2 ) ? 1 : - 1;
409 }
410
411 /**
412 * Filter out all products which have been fully discounted to 0.
413 * Used as array_filter callback.
414 *
415 * @param object $item Get data for this item.
416 *
417 * @return bool
418 */
419 protected function filter_products_with_price( object $item ): bool {
420 return $this->get_discounted_price_in_cents( $item ) > 0;
421 }
422
423 /**
424 * Get items which the coupon should be applied to.
425 *
426 * @param Coupon $coupon Coupon object.
427 *
428 * @return array<int|string,object>
429 */
430 protected function get_items_to_apply_coupon( Coupon $coupon ): array {
431 $items_to_apply = [];
432
433 foreach ( $this->get_items_to_validate() as $item ) {
434 $to_apply = clone $item; // Clone the item so changes to this item do not affect the originals.
435
436 if ( 0 === $this->get_discounted_price_in_cents( $to_apply ) || 0 >= $to_apply->quantity ) {
437 continue;
438 }
439
440 $product_id = $to_apply->product_id ?? $to_apply->object->product_id;
441 $price_id = $to_apply->price_id ?? $to_apply->object->price_id;
442
443 if ( ! $coupon->is_valid_for_product( $product_id, $price_id, $to_apply->object ) /*&& ! $coupon->is_valid_for_cart()*/ ) {
444 continue;
445 }
446
447 $items_to_apply[] = $to_apply;
448 }
449
450 /**
451 * Filters the items that a coupon should be applied to.
452 *
453 * This filter allows you to modify the items that a coupon will be applied to before the discount calculations take place.
454 *
455 * @param array $items_to_apply The items that the coupon will be applied to.
456 * @param Coupon $coupon The coupon object.
457 * @param Discounts $this The discounts instance.
458 *
459 * @return array The modified list of items that the coupon should be applied to.
460 */
461 return apply_filters( 'storeengine/coupon_get_items_to_apply', $items_to_apply, $coupon, $this );
462 }
463
464 /**
465 * Apply percent discount to items and return an array of discounts granted.
466 *
467 * @param Coupon $coupon Coupon object. Passed through filters.
468 * @param array<int|string,object> $items_to_apply Array of items to apply the coupon to.
469 *
470 * @return int|float Total discounted.
471 */
472 protected function apply_coupon_percent( Coupon $coupon, array $items_to_apply ) {
473 $total_discount = 0;
474 $cart_total = 0;
475 $limit_usage_qty = 0;
476 $applied_count = 0;
477 $adjust_final_discount = true;
478
479 if ( null !== $coupon->get_limit_usage_to_x_items() ) {
480 $limit_usage_qty = $coupon->get_limit_usage_to_x_items();
481 }
482
483 $coupon_amount = $coupon->get_amount();
484
485 foreach ( $items_to_apply as $item ) {
486 // Find out how much price is available to discount for the item.
487 $discounted_price = $this->get_discounted_price_in_cents( $item );
488
489 // Get the price we actually want to discount, based on settings.
490 $price_to_discount = ( 'yes' === get_option( 'storeengine/calc_discounts_sequentially', 'no' ) ) ? $discounted_price : NumberUtil::round( $item->price );
491
492 // See how many and what price to apply to.
493 $apply_quantity = $limit_usage_qty && ( $limit_usage_qty - $applied_count ) < $item->quantity ? $limit_usage_qty - $applied_count : $item->quantity;
494 $apply_quantity = max( 0, apply_filters( 'storeengine/coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) );
495 $price_to_discount = ( $price_to_discount / $item->quantity ) * $apply_quantity;
496
497 // Run coupon calculations.
498 $discount = floor( $price_to_discount * ( $coupon_amount / 100 ) );
499
500 if ( is_a( $this->object, Cart::class ) && has_filter( 'storeengine/coupon_get_discount_amount' ) ) {
501 // Send through the legacy filter, but not as cents.
502 $filtered_discount = Formatting::add_number_precision( apply_filters( 'storeengine/coupon_get_discount_amount', Formatting::remove_number_precision( $discount ), Formatting::remove_number_precision( $price_to_discount ), $item->object, false, $coupon ) );
503
504 if ( $filtered_discount !== $discount ) {
505 $discount = $filtered_discount;
506 $adjust_final_discount = false;
507 }
508 }
509
510 $discount = Formatting::round_discount( min( $discounted_price, $discount ), 0 );
511 $cart_total = $cart_total + $price_to_discount;
512 $total_discount = $total_discount + $discount;
513 $applied_count = $applied_count + $apply_quantity;
514
515 // Store code and discount amount per item.
516 $this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount;
517 }
518
519 // Work out how much discount would have been given to the cart as a whole and compare to what was discounted on all line items.
520 $cart_total_discount = Formatting::round_discount( $cart_total * ( $coupon_amount / 100 ), 0 );
521
522 if ( $total_discount < $cart_total_discount && $adjust_final_discount ) {
523 $total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $cart_total_discount - $total_discount );
524 }
525
526 return $total_discount;
527 }
528
529 /**
530 * Apply fixed product discount to items.
531 *
532 * @param Coupon $coupon Coupon object. Passed through filters.
533 * @param array<int|string,object> $items_to_apply Array of items to apply the coupon to.
534 * @param int|float $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon.
535 *
536 * @return int|float Total discounted.
537 */
538 protected function apply_coupon_fixed_product( Coupon $coupon, array $items_to_apply, $amount = null ) {
539 $total_discount = 0;
540 /** @noinspection PhpTernaryExpressionCanBeReducedToShortVersionInspection */
541 $amount = $amount ? $amount : Formatting::add_number_precision( $coupon->get_amount() );
542 $limit_usage_qty = 0;
543 $applied_count = 0;
544
545 if ( null !== $coupon->get_limit_usage_to_x_items() ) {
546 $limit_usage_qty = $coupon->get_limit_usage_to_x_items();
547 }
548
549 foreach ( $items_to_apply as $item ) {
550 // Find out how much price is available to discount for the item.
551 $discounted_price = $this->get_discounted_price_in_cents( $item );
552
553 // Get the price we actually want to discount, based on settings.
554 $price_to_discount = ( 'yes' === get_option( 'storeengine/calc_discounts_sequentially', 'no' ) ) ? $discounted_price : $item->price;
555
556 // Run coupon calculations.
557 if ( $limit_usage_qty ) {
558 $apply_quantity = min( $limit_usage_qty - $applied_count, $item->quantity );
559 $apply_quantity = max( 0, apply_filters( 'storeengine/coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) );
560 $discount = min( $amount, $item->price / $item->quantity ) * $apply_quantity;
561 } else {
562 $apply_quantity = apply_filters( 'storeengine/coupon_get_apply_quantity', $item->quantity, $item, $coupon, $this );
563 $discount = $amount * $apply_quantity;
564 }
565
566 if ( is_a( $this->object, Cart::class ) && has_filter( 'storeengine/coupon_get_discount_amount' ) ) {
567 // Send through the legacy filter, but not as cents.
568 $discount = Formatting::add_number_precision( apply_filters( 'storeengine/coupon_get_discount_amount', Formatting::remove_number_precision( $discount ), Formatting::remove_number_precision( $price_to_discount ), $item->object, false, $coupon ) );
569 }
570
571 $discount = min( $discounted_price, $discount );
572 $total_discount = $total_discount + $discount;
573 $applied_count = $applied_count + $apply_quantity;
574
575 // Store code and discount amount per item.
576 $this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount;
577 }
578
579 return $total_discount;
580 }
581
582 /**
583 * Apply fixed cart discount to items.
584 *
585 * @param Coupon $coupon Coupon object. Passed through filters.
586 * @param array<int|string,object> $items_to_apply Array of items to apply the coupon to.
587 * @param int|float $amount Fixed discount amount to apply in cents. Leave blank to pull from coupon.
588 *
589 * @return int|float Total discounted.
590 */
591 protected function apply_coupon_fixed_cart( Coupon $coupon, array $items_to_apply, $amount = null ) {
592 $total_discount = 0;
593 /** @noinspection PhpTernaryExpressionCanBeReducedToShortVersionInspection */
594 $amount = $amount ? $amount : Formatting::add_number_precision( $coupon->get_amount() );
595 $items_to_apply = array_filter( $items_to_apply, array( $this, 'filter_products_with_price' ) );
596 $item_count = array_sum( wp_list_pluck( $items_to_apply, 'quantity' ) );
597
598 if ( ! $item_count ) {
599 return $total_discount;
600 }
601
602 if ( ! $amount ) {
603 // If there is no amount we still send it through so filters are fired.
604 $total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, 0 );
605 } else {
606 $per_item_discount = absint( $amount / $item_count ); // round it down to the nearest cent.
607
608 if ( $per_item_discount > 0 ) {
609 $total_discount = $this->apply_coupon_fixed_product( $coupon, $items_to_apply, $per_item_discount );
610
611 /**
612 * If there is still discount remaining, repeat the process.
613 */
614 if ( $total_discount > 0 && $total_discount < $amount ) {
615 $total_discount += $this->apply_coupon_fixed_cart( $coupon, $items_to_apply, $amount - $total_discount );
616 }
617 } elseif ( $amount > 0 ) {
618 $total_discount += $this->apply_coupon_remainder( $coupon, $items_to_apply, $amount );
619 }
620 }
621
622 return $total_discount;
623 }
624
625 /**
626 * Get the rewarded ("free") unit counts per coupon and item.
627 *
628 * Populated by add-ons via record_reward_units(); this getter just exposes
629 * them to the UI (FREE-line rendering).
630 *
631 * @return array[] Code => Item Key => units (int)
632 */
633 public function get_reward_units(): array {
634 return $this->reward_units;
635 }
636
637 /**
638 * @deprecated Use get_reward_units().
639 *
640 * @return array[] Code => Item Key => units (int)
641 */
642 public function get_bogo_rewards(): array {
643 return $this->get_reward_units();
644 }
645
646 /**
647 * Apply custom coupon discount to items.
648 *
649 * @param Coupon $coupon Coupon object. Passed through filters.
650 * @param array<int|string,object> $items_to_apply Array of items to apply the coupon to.
651 *
652 * @return int|float Total discounted.
653 */
654 protected function apply_coupon_custom( Coupon $coupon, array $items_to_apply ): int {
655 $limit_usage_qty = 0;
656 $applied_count = 0;
657
658 if ( null !== $coupon->get_limit_usage_to_x_items() ) {
659 $limit_usage_qty = $coupon->get_limit_usage_to_x_items();
660 }
661
662 // Apply the coupon to each item.
663 foreach ( $items_to_apply as $item ) {
664 // Find out how much price is available to discount for the item.
665 $discounted_price = $this->get_discounted_price_in_cents( $item );
666
667 // Get the price we actually want to discount, based on settings.
668 $price_to_discount = Formatting::remove_number_precision( ( 'yes' === get_option( 'storeengine/calc_discounts_sequentially', 'no' ) ) ? $discounted_price : $item->price );
669
670 // See how many and what price to apply to.
671 $apply_quantity = $limit_usage_qty && ( $limit_usage_qty - $applied_count ) < $item->quantity ? $limit_usage_qty - $applied_count : $item->quantity;
672 $apply_quantity = max( 0, apply_filters( 'storeengine/coupon_get_apply_quantity', $apply_quantity, $item, $coupon, $this ) );
673
674 // Run coupon calculations.
675 $discount = Formatting::add_number_precision( $coupon->get_discount_amount( $price_to_discount / $item->quantity, $item->object->get_data(), true ) ) * $apply_quantity;
676 $discount = Formatting::round_discount( min( $discounted_price, $discount ), 0 );
677 $applied_count = $applied_count + $apply_quantity;
678
679 // Store code and discount amount per item.
680 $this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount;
681 }
682
683 // Allow post-processing for custom coupon types (e.g. calculating discrepancy, etc).
684 $this->discounts[ strtolower( $coupon->get_code() ) ] = apply_filters( 'storeengine/coupon_custom_discounts_array', $this->discounts[ strtolower( $coupon->get_code() ) ], $coupon );
685
686 return array_sum( $this->discounts[ strtolower( $coupon->get_code() ) ] );
687 }
688
689 /**
690 * Deal with remaining fractional discounts by splitting it over items
691 * until the amount is expired, discounting 1 cent at a time.
692 *
693 * @param Coupon $coupon Coupon object if applicable. Passed through filters.
694 * @param array<int|string,object> $items_to_apply Array of items to apply the coupon to.
695 * @param int|float $amount Fixed discount amount to apply.
696 *
697 * @return int|float Total discounted.
698 */
699 protected function apply_coupon_remainder( Coupon $coupon, array $items_to_apply, $amount ) {
700 $total_discount = 0;
701
702 foreach ( $items_to_apply as $item ) {
703 for ( $i = 0; $i < $item->quantity; $i ++ ) {
704 // Find out how much price is available to discount for the item.
705 $price_to_discount = $this->get_discounted_price_in_cents( $item );
706
707 // Run coupon calculations.
708 $discount = min( $price_to_discount, 1 );
709
710 // Store totals.
711 $total_discount += $discount;
712
713 // Store code and discount amount per item.
714 $this->discounts[ strtolower( $coupon->get_code() ) ][ $item->key ] += $discount;
715
716 if ( $total_discount >= $amount ) {
717 break 2;
718 }
719 }
720 if ( $total_discount >= $amount ) {
721 break;
722 }
723 }
724
725 return $total_discount;
726 }
727
728 /**
729 * Ensure coupon exists or throw exception.
730 *
731 * A coupon is also considered to no longer exist if it has been placed in the trash, even if the trash has not yet
732 * been emptied.
733 *
734 * @param Coupon $coupon Coupon data.
735 *
736 * @return bool
737 * @throws Exception Error message.
738 */
739 protected function validate_coupon_exists( Coupon $coupon ): bool {
740 if ( ! $coupon->get_id() || 'trash' === $coupon->get_status() ) {
741 /* translators: %s: coupon code */
742 throw new Exception( sprintf( esc_html__( 'Coupon "%s" does not exist!', 'storeengine' ), esc_html( $coupon->get_code() ) ), 105 );
743 }
744
745 return true;
746 }
747
748 /**
749 * Ensure coupon usage limit is valid or throw exception.
750 *
751 * @param Coupon $coupon Coupon data.
752 *
753 * @return bool
754 * @throws StoreEngineException Error message.
755 */
756 protected function validate_coupon_usage_limit( Coupon $coupon ): bool {
757 if ( ! $coupon->get_usage_limit() ) {
758 return true;
759 }
760
761 $usage_count = $coupon->get_usage_count();
762 $held_coupon_count = 0; // @see order held-coupon.
763
764 if ( $usage_count + $held_coupon_count < $coupon->get_usage_limit() ) {
765 // All good.
766 return true;
767 }
768
769 throw new StoreEngineException(
770 sprintf(
771 // translators: %s: Coupon Code.
772 esc_html__( 'Sorry, Coupon "%s" has reached its limit', 'storeengine' ),
773 esc_html( $coupon->get_code() )
774 ),
775 'coupon-limit-reached',
776 null,
777 400
778 );
779 }
780
781 /**
782 * Ensure coupon user usage limit is valid or throw exception.
783 *
784 * Per user usage limit - check here if user is logged in (against user IDs).
785 * Checked again for emails later in the cart's customer-coupon check.
786 *
787 * @param Coupon $coupon Coupon data.
788 * @param int $user_id User ID.
789 *
790 * @throws StoreEngineException Error message.
791 */
792 protected function validate_coupon_user_usage_limit( Coupon $coupon, int $user_id = 0 ): bool {
793 if ( empty( $user_id ) ) {
794 if ( $this->object instanceof Order ) {
795 $user_id = $this->object->get_customer_id();
796 } else {
797 $user_id = get_current_user_id();
798 }
799 }
800
801 if ( $user_id && apply_filters( 'storeengine/coupon/validate_user_usage_limit', $coupon->get_usage_limit_per_user() > 0, $user_id, $coupon, $this ) && $coupon->get_id() ) {
802 if ( $coupon->get_usage_by_user_id( $user_id ) >= $coupon->get_usage_limit_per_user() ) {
803 // Check if held for the user and change error message.
804 throw new StoreEngineException(
805 sprintf(
806 // translators: %s: Coupon Code.
807 esc_html__( 'Sorry, Coupon "%s" has reached its limit', 'storeengine' ),
808 esc_html( $coupon->get_code() )
809 ),
810 'coupon-limit-reached',
811 null,
812 400
813 );
814 }
815 }
816
817 return true;
818 }
819
820 /**
821 * Ensure coupon date is valid or throw exception.
822 *
823 * @param Coupon $coupon Coupon data.
824 *
825 * @return bool
826 * @throws Exception Error message.
827 */
828 protected function validate_coupon_expiry_date( Coupon $coupon ): bool {
829 if ( $coupon->get_date_expires() && apply_filters( 'storeengine/coupon_validate_expiry_date', time() > $coupon->get_date_expires()->getTimestamp(), $coupon, $this ) ) {
830 /* translators: %s: coupon code */
831 throw new Exception( sprintf( esc_html__( 'This coupon (%s) has expired.', 'storeengine' ), esc_html( $coupon->get_code() ) ), 107 );
832 }
833
834 return true;
835 }
836
837 /**
838 * Ensure coupon amount is valid or throw exception.
839 *
840 * @param Coupon $coupon Coupon data.
841 *
842 * @return bool
843 * @throws Exception Error message.
844 */
845 protected function validate_coupon_minimum_amount( Coupon $coupon ): bool {
846 $subtotal = Formatting::remove_number_precision( $this->get_object_subtotal() );
847
848 if ( $coupon->get_minimum_amount() > 0 && apply_filters( 'storeengine/coupon_validate_minimum_amount', $coupon->get_minimum_amount() > $subtotal, $coupon, $subtotal ) ) {
849 /* translators: %s: coupon minimum amount */
850 throw new Exception( sprintf( esc_html__( 'The minimum spend for this coupon is %s.', 'storeengine' ), wp_kses_post( Formatting::price( $coupon->get_minimum_amount() ) ) ), 108 );
851 }
852
853 return true;
854 }
855
856 /**
857 * Ensure coupon amount is valid or throw exception.
858 *
859 * @param Coupon $coupon Coupon data.
860 *
861 * @return bool
862 * @throws Exception Error message.
863 */
864 protected function validate_coupon_maximum_amount( Coupon $coupon ): bool {
865 $subtotal = Formatting::remove_number_precision( $this->get_object_subtotal() );
866
867 if ( $coupon->get_maximum_amount() > 0 && apply_filters( 'storeengine/coupon_validate_maximum_amount', $coupon->get_maximum_amount() < $subtotal, $coupon ) ) {
868 /* translators: %s: coupon maximum amount */
869 throw new Exception( sprintf( esc_html__( 'The maximum spend for this coupon is %s.', 'storeengine' ), wp_kses_post( Formatting::price( $coupon->get_maximum_amount() ) ) ), 112 );
870 }
871
872 return true;
873 }
874
875 /**
876 * Ensure coupon is valid for products in the list is valid or throw exception.
877 *
878 * @param Coupon $coupon Coupon data.
879 *
880 * @return bool
881 * @throws Exception Error message.
882 */
883 protected function validate_coupon_product_ids( Coupon $coupon ): bool {
884 if ( count( $coupon->get_product_ids() ) > 0 ) {
885 $valid = false;
886
887 foreach ( $this->get_items_to_validate() as $item ) {
888 if ( ( $item->product_id && in_array( $item->product_id, $coupon->get_product_ids(), true ) ) || ( ! empty( $item->product_parent_id ) && in_array( $item->product_parent_id, $coupon->get_product_ids(), true ) ) ) {
889 $valid = true;
890 break;
891 }
892 }
893
894 if ( ! $valid ) {
895 throw new Exception( esc_html__( 'Sorry, this coupon is not applicable to selected products.', 'storeengine' ), 109 );
896 }
897 }
898
899 return true;
900 }
901
902 /**
903 * Ensure coupon is valid for product categories in the list is valid or throw exception.
904 *
905 * @param Coupon $coupon Coupon data.
906 *
907 * @return bool
908 * @throws Exception Error message.
909 */
910 protected function validate_coupon_product_categories( Coupon $coupon ): bool {
911 if ( count( $coupon->get_product_categories() ) > 0 ) {
912 $valid = false;
913
914 foreach ( $this->get_items_to_validate() as $item ) {
915 if ( $coupon->get_exclude_sale_items() && $item->product_id && $item->price && $item->compare_price > 0 ) {
916 continue;
917 }
918
919 $product_cats = Helper::get_product_cat_ids( $item->product_id );
920
921 if ( ! empty( $item->product_parent_id ) ) {
922 $product_cats = array_merge( $product_cats, Helper::get_product_cat_ids( $item->product_parent_id ) );
923 }
924
925 // If we find an item with a cat in our allowed cat list, the coupon is valid.
926 if ( count( array_intersect( $product_cats, $coupon->get_product_categories() ) ) > 0 ) {
927 $valid = true;
928 break;
929 }
930 }
931
932 if ( ! $valid ) {
933 throw new Exception( esc_html__( 'Sorry, this coupon is not applicable to selected products.', 'storeengine' ), 109 );
934 }
935 }
936
937 return true;
938 }
939
940 /**
941 * Ensure coupon is valid for sale items in the list is valid or throw exception.
942 *
943 * @param Coupon $coupon Coupon data.
944 *
945 * @return bool
946 * @throws Exception Error message.
947 */
948 protected function validate_coupon_sale_items( Coupon $coupon ): bool {
949 if ( $coupon->get_exclude_sale_items() ) {
950 $valid = true;
951
952 foreach ( $this->get_items_to_validate() as $item ) {
953 if ( $item->product_id && $item->price && $item->compare_price > 0 ) {
954 $valid = false;
955 break;
956 }
957 }
958
959 if ( ! $valid ) {
960 throw new Exception( esc_html__( 'Sorry, this coupon is not valid for sale items.', 'storeengine' ), 110 );
961 }
962 }
963
964 return true;
965 }
966
967 /**
968 * All exclusion rules must pass at the same time for a product coupon to be valid.
969 *
970 * @param Coupon $coupon Coupon data.
971 *
972 * @throws Exception Error message.
973 */
974 protected function validate_coupon_excluded_items( Coupon $coupon ) {
975 // @TODO validate excluded product/items.
976 }
977
978 /**
979 * Cart discounts cannot be added if non-eligible product is found.
980 *
981 * @param Coupon $coupon Coupon data.
982 *
983 * @return bool
984 * @throws Exception Error message.
985 */
986 protected function validate_coupon_eligible_items( Coupon $coupon ): bool {
987 // Exclusion rules apply to every coupon type (matching the include-side
988 // product/category validators in is_coupon_valid()). The previous
989 // `! is_type( get_coupon_types() )` gate skipped these for the two standard
990 // types (percentage / fixedAmount), so exclude-product, exclude-category and
991 // exclude-sale-items silently did nothing for normal coupons.
992 $this->validate_coupon_sale_items( $coupon );
993 $this->validate_coupon_excluded_product_ids( $coupon );
994 $this->validate_coupon_excluded_product_categories( $coupon );
995
996 return true;
997 }
998
999 /**
1000 * Exclude products.
1001 *
1002 * @param Coupon $coupon Coupon data.
1003 *
1004 * @return bool
1005 * @throws Exception Error message.
1006 */
1007 protected function validate_coupon_excluded_product_ids( Coupon $coupon ): bool {
1008 // Exclude Products.
1009 if ( count( $coupon->get_excluded_product_ids() ) > 0 ) {
1010 $products = [];
1011
1012 foreach ( $this->get_items_to_validate() as $item ) {
1013 $product_parent_id = $item->product_parent_id ?? 0;
1014 if ( ( $item->product_id && in_array( $item->product_id, $coupon->get_excluded_product_ids(), true ) ) || ( $product_parent_id && in_array( $product_parent_id, $coupon->get_excluded_product_ids(), true ) ) ) {
1015 $products[] = $item->object->name;
1016 }
1017 }
1018
1019 if ( ! empty( $products ) ) {
1020 /* translators: %s: products list */
1021 throw new Exception( sprintf( esc_html__( 'Sorry, this coupon is not applicable to the products: %s.', 'storeengine' ), esc_html( implode( ', ', $products ) ) ), 113 );
1022 }
1023 }
1024
1025 return true;
1026 }
1027
1028 /**
1029 * Exclude categories from product list.
1030 *
1031 * @param Coupon $coupon Coupon data.
1032 *
1033 * @return bool
1034 * @throws Exception Error message.
1035 */
1036 protected function validate_coupon_excluded_product_categories( Coupon $coupon ): bool {
1037 if ( count( $coupon->get_excluded_product_categories() ) > 0 ) {
1038 $categories = [];
1039
1040 foreach ( $this->get_items_to_validate() as $item ) {
1041 if ( empty( $item->product_id ) ) {
1042 continue;
1043 }
1044
1045 $product_cats = Helper::get_product_cat_ids( $item->product_id );
1046
1047 if ( ! empty( $item->product_parent_id ) ) {
1048 $product_cats = array_merge( $product_cats, Helper::get_product_cat_ids( $item->product_parent_id ) );
1049 }
1050
1051 $cat_id_list = array_intersect( $product_cats, $coupon->get_excluded_product_categories() );
1052 if ( count( $cat_id_list ) > 0 ) {
1053 foreach ( $cat_id_list as $cat_id ) {
1054 $cat = get_term( $cat_id, Helper::PRODUCT_CATEGORY_TAXONOMY );
1055 $categories[] = $cat->name;
1056 }
1057 }
1058 }
1059
1060 if ( ! empty( $categories ) ) {
1061 /* translators: %s: categories list */
1062 throw new Exception( sprintf( esc_html__( 'Sorry, this coupon is not applicable to the categories: %s.', 'storeengine' ), esc_html( implode( ', ', array_unique( $categories ) ) ) ), 114 );
1063 }
1064 }
1065
1066 return true;
1067 }
1068
1069 /**
1070 * Ensure coupon is valid for allowed emails or throw exception.
1071 *
1072 * @param Coupon $coupon Coupon data.
1073 *
1074 * @return bool
1075 * @throws Exception Error message.
1076 */
1077 protected function validate_coupon_allowed_emails( Coupon $coupon ): bool {
1078 $restrictions = $coupon->get_email_restrictions();
1079
1080 if ( ! is_array( $restrictions ) || empty( $restrictions ) ) {
1081 return true;
1082 }
1083
1084 $user = wp_get_current_user();
1085 $check_emails = array( $user->user_email );
1086
1087 if ( $this->object instanceof Cart ) {
1088 $check_emails[] = $this->object->get_customer()->get_billing_email();
1089 } elseif ( $this->object instanceof Order ) {
1090 $check_emails[] = $this->object->get_billing_email();
1091 }
1092
1093 $check_emails = array_unique( array_filter( array_map( 'strtolower', array_map( 'sanitize_email', $check_emails ) ) ) );
1094
1095 if ( ! self::is_coupon_emails_allowed( $check_emails, $restrictions ) ) {
1096 // We check for supplied billing email. On shortcode, this will be present for checkout requests.
1097 $billing_email = $_POST['billing_email'] ?? null; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
1098 if ( ! is_null( $billing_email ) ) {
1099 /* translators: %s: coupon code */
1100 $err = sprintf( __( 'Please enter a valid email to use coupon code "%s".', 'storeengine' ), esc_html( $coupon->get_code() ) );
1101 } else {
1102 /* translators: %s: coupon code */
1103 $err = sprintf( __( 'Please enter a valid email at checkout to use coupon code "%s".', 'storeengine' ), esc_html( $coupon->get_code() ) );
1104 }
1105 throw new Exception( esc_html( $err ), 102 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
1106 }
1107
1108 return true;
1109 }
1110
1111 /**
1112 * Get the object subtotal
1113 *
1114 * @return int
1115 */
1116 protected function get_object_subtotal(): int {
1117 if ( is_a( $this->object, Cart::class ) ) {
1118 return Formatting::add_number_precision( $this->object->get_displayed_subtotal() );
1119 } elseif ( is_a( $this->object, Order::class ) ) {
1120 $subtotal = Formatting::add_number_precision( $this->object->get_subtotal() );
1121
1122 if ( $this->object->get_prices_include_tax() ) {
1123 // Add tax to tax-exclusive subtotal.
1124 $subtotal = $subtotal + Formatting::add_number_precision( NumberUtil::round( $this->object->get_total_tax(), Formatting::get_price_decimals() ) );
1125 }
1126
1127 return $subtotal;
1128 } else {
1129 return array_sum( wp_list_pluck( $this->items, 'price' ) );
1130 }
1131 }
1132
1133 /**
1134 * Check if a coupon is valid.
1135 *
1136 * Error Codes:
1137 * - 100: Invalid filtered.
1138 * - 101: Invalid removed.
1139 * - 102: Not yours removed.
1140 * - 103: Already applied.
1141 * - 104: Individual use only.
1142 * - 105: Not exists.
1143 * - 106: Usage limit reached.
1144 * - 107: Expired.
1145 * - 108: Minimum spend limit not met.
1146 * - 109: Not applicable.
1147 * - 110: Not valid for sale items.
1148 * - 111: Missing coupon code.
1149 * - 112: Maximum spend limit met.
1150 * - 113: Excluded products.
1151 * - 114: Excluded categories.
1152 *
1153 * @param Coupon $coupon Coupon data.
1154 *
1155 * @return bool|WP_Error
1156 */
1157 public function is_coupon_valid( Coupon $coupon ) {
1158 /**
1159 * Short-circuit coupon validation. Return a non-null value (bool true or
1160 * WP_Error) to bypass the standard checks entirely. Used by the
1161 * subscription recurring-discount feature to re-apply a locked-in coupon
1162 * at renewal even after it has expired or hit its usage limit.
1163 *
1164 * @param null|bool|WP_Error $pre Short-circuit value. Default null (run checks).
1165 * @param Coupon $coupon The coupon being validated.
1166 * @param Discounts $this The discounts context.
1167 */
1168 $pre = apply_filters( 'storeengine/pre_is_coupon_valid', null, $coupon, $this );
1169 if ( null !== $pre ) {
1170 return $pre;
1171 }
1172
1173 try {
1174 $this->validate_coupon_exists( $coupon );
1175 $this->validate_coupon_usage_limit( $coupon );
1176 $this->validate_coupon_user_usage_limit( $coupon );
1177 $this->validate_coupon_expiry_date( $coupon );
1178 $this->validate_coupon_minimum_amount( $coupon );
1179 $this->validate_coupon_maximum_amount( $coupon );
1180 $this->validate_coupon_product_ids( $coupon );
1181 $this->validate_coupon_product_categories( $coupon );
1182 $this->validate_coupon_excluded_items( $coupon );
1183 $this->validate_coupon_eligible_items( $coupon );
1184 $this->validate_coupon_allowed_emails( $coupon );
1185
1186 if ( ! apply_filters( 'storeengine/coupon_is_valid', true, $coupon, $this ) ) {
1187 throw new Exception( esc_html__( 'Coupon is not valid.', 'storeengine' ), 100 );
1188 }
1189 } catch ( StoreEngineException $e ) {
1190 // Expected coupon validation failures (not found / expired / usage
1191 // limit / not applicable) are returned to the customer as a WP_Error,
1192 // not logged as system errors.
1193 return $e->get_wp_error();
1194 } catch ( Exception $e ) {
1195 // Unexpected failures still get logged.
1196 Helper::log_error( $e );
1197
1198 return new WP_Error( 'invalid_coupon', $e->getMessage(), [ 'status' => 400 ] );
1199 }
1200
1201 return true;
1202 }
1203
1204 /**
1205 * Checks if the given email address(es) matches the ones specified on the coupon.
1206 *
1207 * @param array $check_emails Array of customer email addresses.
1208 * @param array $restrictions Array of allowed email addresses.
1209 *
1210 * @return bool
1211 */
1212 public static function is_coupon_emails_allowed( array $check_emails, array $restrictions ): bool {
1213 foreach ( $check_emails as $check_email ) {
1214 // With a direct match we return true.
1215 if ( in_array( $check_email, $restrictions, true ) ) {
1216 return true;
1217 }
1218
1219 // Go through the allowed emails and return true if the email matches a wildcard.
1220 foreach ( $restrictions as $restriction ) {
1221 // Convert to PHP-regex syntax.
1222 $regex = '/^' . str_replace( '*', '(.+)?', $restriction ) . '$/';
1223 preg_match( $regex, $check_email, $match );
1224 if ( ! empty( $match ) ) {
1225 return true;
1226 }
1227 }
1228 }
1229
1230 // No matches, this one isn't allowed.
1231 return false;
1232 }
1233 }
1234
1235 // End of file discounts.php
1236