PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.2
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.2
1.4.15 1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 1.3.49 1.3.48 1.3.47 1.3.46 All 178 releases
disco / app / Intents / IntentHelper.php

IntentHelper.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.2, at app/Intents/IntentHelper.php

1,074 lines 29.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php //phpcs:ignore
2
3 /**
4 * Intent Helper Trait.
5 *
6 * @package Disco
7 * @subpackage \App\Intents\IntentHelper.php
8 * @since 1.0.0
9 */
10
11 namespace Disco\App\Intents;
12
13 use Disco\App\Calc\CalcFactory;
14 use Disco\App\Campaign;
15 use Disco\App\Features\UserLimit;
16 use Disco\App\Utility\Config;
17 use Disco\App\Utility\Helper;
18 use Disco\App\Utility\Settings;
19
20 /**
21 * This Class contains all the common function for cart related intents,
22 * and the intents are Cart, Bulk, Bundle and BOGO
23 *
24 * @package Disco
25 * @subpackage Disco\App\Intents
26 * @author Ohidul Islam <wahid0003@gmail.com>
27 * @link https://webappick.com
28 * @license https://opensource.org/licenses/gpl-license.php GNU Public License
29 * @category IntentHelper
30 */
31 trait IntentHelper {//phpcs:ignore
32
33 /**
34 * Disco Prepare Intents.
35 *
36 * @param array $intents Skip Intents.
37 * Either a single intent or an array of intents.
38 * @return array
39 */
40 public function prepare_intents( $intents = [] ) {//phpcs:ignore
41 $campaigns = ( new Campaign )->get_campaigns( '1' );
42
43
44 $new_intents = array();
45
46 $settings = Settings::get();
47
48 if ( empty( $campaigns ) ) {
49 return $new_intents;
50 }
51
52 foreach ( $campaigns as $campaign ) {
53 // Check discount expiration date.
54 if ( ! Helper::is_in_valid_date( $campaign ) ) {
55 continue;
56 }
57
58 // Skip the intent if it is in the skip list.
59 if (
60 ! empty( $intents )
61 && is_array( $intents )
62 && ! in_array( $campaign->discount_intent, $intents, true )
63 ) {
64 continue;
65 }
66
67 if ( $campaign->discount_intent === 'BOGO' ) {
68 $campaign->discount_intent = 'BuyXGetX';
69
70 if ( in_array( $campaign->bogo_type, array( 'products', 'categories' ), true ) ) {
71 $campaign->discount_intent = 'BuyXGetY';
72 }
73 }
74
75 $new_intents[] = IntentFactory::get_intent( $campaign, $settings );
76 }//end foreach
77
78 return $new_intents;
79 }
80
81 /**
82 * Get discount applicable items from the cart.
83 *
84 * @param \WC_Cart $cart Cart Object.
85 * @param \Disco\App\Utility\Config $campaign Campaign Config.
86 * @return array
87 */
88 public function get_items_for_discount( $cart, $campaign ) { //phpcs:ignore
89 $items = array();
90 $discount_type = $campaign->get_discount_intent();
91 $cart_items = $cart->get_cart();
92 $bogo_type = $campaign->get_bogo_type();
93 $y_products = array();
94 $verified_yproducts = false;
95
96 /**
97 * Check if the discount type is BuyXGetY and the Y product is in the cart.
98 * If so, add the Y product to the items array.
99 */
100 if ( $discount_type === 'BuyXGetY' ) {
101 $rule_ids = $campaign->get_rule_product_ids();
102 $y_products = $this->get_y_product( $rule_ids, $cart_items, $bogo_type );
103 $verified_yproducts = $this->verify_yproduct_in_cart( $rule_ids, $cart_items, $bogo_type );
104 }
105
106 foreach ( $cart_items as $item ) {
107 // Skip free products - they should not count towards BOGO buy quantity
108 if ( ! empty( $item['is_free_product'] ) ) {
109 continue;
110 }
111
112 $id = $item['product_id'];
113
114 if ( $item['variation_id'] ) {
115 $id = $item['variation_id'];
116 }
117
118 // Continue if the product is not applicable for the campaign.
119 if ( ! $campaign->product_is_applicable( $id ) ) {
120 continue;
121 }
122
123 // Continue if the item is not passed the filter.
124 $product = wc_get_product( $id );
125
126 $info = array( 'product' => $product );
127
128 if ( ! Helper::is_filter_passed( $campaign, $info ) ) {
129 continue;
130 }
131
132 /**
133 * Check if the discount type is BuyXGetY and the Y product is in the cart.
134 * If so, add the Y product to the items array.
135 * These products not need to be passed the filter.
136 */
137 if ( $discount_type === 'BuyXGetY' && $verified_yproducts ) {
138 $items = array_merge( $items, $y_products );
139 }
140
141 $items[] = $item;
142 }//end foreach
143
144 return $items;
145 }
146
147 /**
148 * Verify the rules.
149 *
150 * @param \Disco\App\Utility\Config $campaign Campaign Config.
151 * @param array $item Cart Item.
152 * @param array $rule Cart Item.
153 * @return bool
154 */
155 public function verify_rules( $campaign, $item, $rule ) {
156 if ( is_object( $rule ) ) {
157 $rule = (array) $rule;
158 }
159
160 $basis = $this->get_basis_for_item( $item );
161
162 if ( empty( $rule ) ) {
163 return false;
164 }
165
166 switch ( $campaign->get_discount_intent() ) {
167 case 'Product':
168 case 'Cart':
169 return isset( $rule['discount_value'], $rule['discount_type'] );
170
171 case 'Bulk':
172 return $this->verify_bulk_rule( $basis, $rule );
173
174 case 'Bundle':
175 return $this->verify_bundle_rule( $basis, $rule );
176
177 case 'BuyXGetX':
178 return $this->verify_buyxgetx_rule( $basis, $item, $rule );
179
180 case 'BuyXGetY':
181 return $this->verify_buyxgety_rule( $campaign, $basis, $item, $rule );
182
183 default:
184 return false;
185 }
186 }
187
188 /**
189 * Get cart item id.
190 *
191 * @param array $item Cart Item.
192 * @return int
193 */
194 public function get_cart_item_id( $item ) {
195 $id = $item['product_id'];
196
197 if ( $item['variation_id'] > 0 ) {
198 $id = $item['variation_id'];
199 }
200
201 return $id;
202 }
203
204 /**
205 * Get the basis for cart.
206 * Returns the quantity or price of the cart.
207 * If the cart is not valid, then return 0.
208 *
209 * @param \Disco\App\Utility\Config $campaign Discount Rules.
210 * @param \WC_Cart $cart Cart .
211 * @return float|int
212 */
213 public function get_basis_for_cart( $campaign, $cart ) {
214 if ( ! $cart instanceof \WC_Cart || $cart->is_empty() ) {
215 return 0;
216 }
217
218 if ( 'cart_quantity' === $campaign->get_discount_based_on() ) {
219 return absint( $cart->get_cart_contents_count() );
220 }
221
222 return abs( $cart->get_subtotal() );
223 }
224
225 /**
226 * Get the basis for item.
227 * Returns the quantity or price of the item.
228 * If the item is not valid, then return 0.
229 *
230 * @param array $item Cart Item.
231 * @return int
232 */
233 public function get_basis_for_item( $item ) {
234 if ( empty( $item['quantity'] ) ) {
235 return 0;
236 }
237
238 return absint( $item['quantity'] );
239 }
240
241 /**
242 * Calculate discount.
243 * Returns the discount amount.
244 *
245 * @param float $cost Item Price or Cart Subtotal.
246 * @param string $discount_type Discount Type.
247 * @param float $discount_value Discount Value.
248 * @return float
249 */
250 public function calculate_discount( $cost, $discount_type, $discount_value ) {
251 $cost = (float) $cost;
252 $discount_value = (float) $discount_value;
253
254 if ( 'percent' === $discount_type ) {
255 $cost = $cost * $discount_value / 100;
256 }
257
258 if ( 'fixed' === $discount_type || 'fixed_per_product' === $discount_type ) {
259 $cost = $discount_value;
260 }
261
262 return $cost;
263 }
264
265 /**
266 * Get the discounted price.
267 *
268 * @param float $cost Item Price or Cart Subtotal.
269 * @param float $discounted_amount Discounted Amount.
270 * @return float Discounted Price.
271 */
272 public function discounted_price( $cost, $discounted_amount ) {
273 $discounted_price = $cost - $discounted_amount;
274
275 if ( $discounted_price < 0 ) {
276 $discounted_price = $cost;
277 }
278
279 return $discounted_price;
280 }
281
282 /**
283 * Get the discounted amount.
284 *
285 * @param float $cost Item Price or Cart Subtotal.
286 * @param float $discounted_amount Discounted Amount.
287 */
288 public function get_amount_after_discount( float $cost, float $discounted_amount ): float {
289 $discounted_amount = $cost - $discounted_amount;
290
291 if ( $discounted_amount < 0 ) {
292 $discounted_amount = $cost;
293 }
294
295 return $discounted_amount;
296 }
297
298 /**
299 * Check if a number is a multiple of another number for recursive discount.
300 *
301 * @param float|int $number Number to check.
302 * @param float|int $of Number to check against.
303 * @return bool Returns true if the number is a multiple of the given number, false otherwise.
304 */
305 public function is_multiple( $number, $of ) {
306 if ( $of >= $number ) {
307 return (float) $of % (float) $number === 0;
308 }
309
310 return false;
311 }
312
313 /**
314 * Check if a product is in a category.
315 *
316 * @param int $product_id Product ID.
317 * @param array $categories Categories.
318 * @return bool
319 */
320 public function is_in_category( $product_id, $categories ) {
321 // Ensure $categories is an array
322 if ( ! is_array( $categories ) ) {
323 $categories = array( $categories );
324 }
325
326 $get_product = wc_get_product( $product_id );
327
328 if ( ! $get_product ) {
329 return false;
330 }
331
332 if ( $get_product->get_type() === 'variation' ) {
333 $product_id = $get_product->get_parent_id();
334 }
335
336 $terms = get_the_terms( $product_id, 'product_cat' );
337
338 if ( ! $terms || is_wp_error( $terms ) ) {
339 return false;
340 }
341
342 $term_ids = array();
343
344 foreach ( $terms as $term ) {
345 $term_ids[] = $term->term_id;
346
347 // Include all parent category IDs
348 $parent_ids = get_ancestors( $term->term_id, 'product_cat' );
349
350 if ( empty( $parent_ids ) ) {
351 continue;
352 }
353
354 $term_ids = array_merge( $term_ids, $parent_ids );
355 }
356
357 // Remove duplicates just in case
358 $term_ids = array_unique( $term_ids );
359 $common_values = array_intersect( $term_ids, $categories );
360
361 return ! empty( $common_values );
362 }
363
364 /**
365 * Prepare a discount array for cart.
366 * Returns the discount array.
367 *
368 * @param \Disco\App\Utility\Config $campaign Campaign Config.
369 * @param array $items Cart Item Object.
370 * @param \WC_Cart $cart Cart Object.
371 */
372 public function get_item_discounts( Config $campaign, array $items, \WC_Cart $cart ): array {//phpcs:ignore
373 $discounts = array();
374 // $cart = WC()->cart;
375 // Get the discount rules.
376 $rules = $campaign->get_discount_rules();
377
378 // Check if the cart is not empty.
379 if ( empty( $items ) ) {
380 return $discounts;
381 }
382
383 /**
384 * Combined qualifying quantity for recursive BOGO, filtered by the
385 * conditions and few-products filters and excluding Disco free items.
386 */
387 $total_applicable_qty = $this->get_total_applicable_quantity( $cart, $campaign );
388
389 // Loop through the cart items.
390 foreach ( $items as $item ) {
391 // Get the item object.
392 $item_objet = $item['data'];
393
394 // Check if the campaign has rules.
395 if ( ! is_array( $rules ) || empty( $rules ) ) {
396 continue;
397 }
398
399 // Loop through the discount rules.
400 foreach ( $rules as $rule ) {
401 if ( is_object( $rule ) ) {
402 $rule = (array) $rule;
403 }
404
405 // Verify the rule by campaign settings.
406 if ( ! $this->verify_rules( $campaign, $item, $rule ) ) {
407 continue;
408 }
409
410 $r = md5( microtime() . wp_rand() );
411 $id = $this->get_cart_item_id( $item );
412
413 // Set the discount array.
414 $discounts[ $id ]['original_price'] = $item_objet->get_price();
415 $discounts[ $id ]['cart_item_key'] = $item['key'];
416
417 // Set discount applies to
418 $discounts[ $id ]['discount_applies_to'][ $r ] = CalcFactory::discount_applies_to( $rule, $campaign );
419
420 // Set Discounts.
421 if ( $rule['discount_type'] === 'free' ) {
422 $discounted_amount = array(
423 'price' => $item_objet->get_price(),
424 'discount' => 0,
425 'line_subtotal' => $item_objet->get_price() * $item['quantity'],
426 'discounted_quantities' => 0,
427 );
428 $discounts[ $id ]['free'] = true;
429 $discounts[ $id ]['get_ids'] = $rule['get_ids'];
430 $discounts[ $id ]['get_qty'] = CalcFactory::get_free_quantity( $rule, $item );
431
432 /**
433 * Recursive BOGO (any bogo type): base the free quantity on the
434 * combined qualifying quantity instead of this single line, so a
435 * cart whose qualifying quantity totals 4 grants 4 free items.
436 */
437 if ( 'yes' === $rule['recursive'] && (int) $rule['min'] > 0 ) {
438 $discounts[ $id ]['get_qty'] = (int) floor( $total_applicable_qty / (int) $rule['min'] ) * (int) $rule['get_quantity'];
439 }
440 } else {
441 $discounted_amount = CalcFactory::get_discount( $rule, $item, $cart, $campaign );
442 $discounts[ $id ]['free'] = false;
443 $discounts[ $id ]['get_ids'] = array();
444 $discounts[ $id ]['get_qty'] = 0;
445 }
446
447 // $discounts[ $id ]['get_ids'] = array_values( $rule['get_ids'] );
448
449 $discounts[ $id ]['discount_types'][ $r ] = $rule['discount_type'];
450 $discounts[ $id ]['intent'][ $r ] = $campaign->get_discount_intent();
451 $discounts[ $id ]['prices'][ $r ] = $discounted_amount['price'];
452 $discounts[ $id ]['discounts'][ $r ] = $discounted_amount['discount'];
453 $discounts[ $id ]['subtotals'][ $r ] = $discounted_amount['line_subtotal'];
454 $discounts[ $id ]['discounted_quantities'][ $r ] = $discounted_amount['discounted_quantities'];
455 $discounts[ $id ]['quantities'][ $r ] = $item['quantity'];
456 $offers = $this->get_item_offers( $rule );
457
458 $discounts[ $id ]['offers'][ $r ] = $offers;
459 }//end foreach
460 }//end foreach
461
462 return $discounts;
463 }
464
465 /**
466 * Prepare discounts for cart items.
467 *
468 * @param array $intents Intents.
469 * @param \WC_Cart $cart Cart.
470 * @return array|false
471 */
472 public function prepare_item_discounts( array $intents, \WC_Cart $cart ) { //phpcs:ignore
473 if ( empty( $intents ) ) {
474 return false;
475 }
476
477 $discounts = array();
478
479 // Loop through the intents.
480 foreach ( $intents as $intent ) {
481 /**
482 * Get a discount limit form campaign.
483 *
484 * Compare with total product meta and apply discount
485 */
486 $discount_limit = $intent->campaign->discount_max_user;
487 $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
488
489 if ( ! empty( $discount_limit ) && ( $discount_limit >= 0 && $total_applied_campaign >= $discount_limit ) ) {
490 continue;
491 }
492
493 $items = $this->get_items_for_discount( $cart, $intent->campaign );
494 $get_discounts = $intent->get_discounts( $items, $cart );
495
496 if ( empty( $get_discounts ) ) {
497 continue;
498 }
499
500 // Only stage campaigns whose discount actually applied to the cart.
501 ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id );
502
503 foreach ( $get_discounts as $item_id => $discount ) {
504 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
505 }
506 }
507
508 // Check if the discounts are empty.
509 if ( empty( $discounts ) ) {
510 return false;
511 }
512
513 // Get the min or max discount amount for each item.
514 foreach ( $discounts as $item_id => $discount ) {
515 $discounts[ $item_id ] = $this->min_max_average( $discount['discounts'] );
516 }
517
518 return $discounts;
519 }
520
521 /**
522 * Get valid BOGO category item ID.
523 *
524 * @param array $intents Intents.
525 * @param \WC_Cart $cart Cart.
526 * @param array $discounts Discounts.
527 * @return int|null
528 */
529 public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {
530 foreach ( $intents as $intent ) {
531 if (
532 $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
533 $intent->campaign->get_bogo_type() !== 'categories'
534 ) {
535 continue;
536 }
537
538 foreach ( $cart->get_cart() as $item ) {
539 $item_id = $this->get_cart_item_id( $item );
540
541 if ( isset( $discounts[ $item_id ] ) && $discounts[ $item_id ] > 0 ) {
542 return $item_id;
543 }
544 }
545 }
546
547 return null;
548 }
549
550 /**
551 * Prepare discounts for cart items.
552 *
553 * @param array $intents Intents.
554 * @param \WC_Cart $cart Cart.
555 * @return array|false
556 */
557 public function prepare_item_discounts_bogo_free( array $intents, \WC_Cart $cart ) { //phpcs:ignore
558 if ( empty( $intents ) ) {
559 return false;
560 }
561
562 $discounts = array();
563
564 // Loop through the intents.
565 foreach ( $intents as $intent ) {
566 /**
567 * Get a discount limit form campaign.
568 *
569 * Compare with total product meta and apply discount
570 */
571 $discount_limit = $intent->campaign->discount_max_user;
572 $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
573
574 if ( ! empty( $discount_limit ) && ( $discount_limit >= 0 && $total_applied_campaign >= $discount_limit ) ) {
575 continue;
576 }
577
578 $items = $this->get_items_for_discount( $cart, $intent->campaign );
579 $get_discounts = $intent->get_discounts( $items, $cart );
580
581 if ( empty( $get_discounts ) ) {
582 continue;
583 }
584
585 // Only stage campaigns whose discount actually applied to the cart.
586 ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id );
587
588 foreach ( $get_discounts as $item_id => $discount ) {
589 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
590 $discounts[ $item_id ]['free'] = $discount['free'];
591 $discounts[ $item_id ]['get_ids'] = $discount['get_ids'];
592 $discounts[ $item_id ]['get_qty'] = $discount['get_qty'];
593 $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type();
594 }
595 }
596
597 // Check if the discounts are empty.
598 if ( empty( $discounts ) ) {
599 return false;
600 }
601
602 // Get the min or max discount amount for each item.
603 foreach ( $discounts as $item_id => $discount ) {
604 $discounts['discount'] = $this->min_max_average( $discount['discounts'] );
605 $discounts['free'] = $discount['free'];
606 $discounts['get_ids'] = $discount['get_ids'] ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); //@phpcs:ignore
607 $discounts['get_qty'] = $discount['get_qty'];
608 $discounts['bogo_type'] = $discount['bogo_type'] ?? 'products';
609 }
610
611 return $discounts;
612 }
613
614 /**
615 * Is it a bogo campaign?
616 *
617 * @param \Disco\App\Utility\Config $campaign Campaign Config.
618 */
619 public function is_bogo( Config $campaign ): bool {
620 return in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true );
621 }
622
623 /**
624 * Check cart is valid or not
625 */
626 public function cart_is_valid(): bool {
627 return WC()->cart instanceof \WC_Cart && ! WC()->cart->is_empty();
628 }
629
630 /**
631 * Get min or max price from an array discounted prices according to plugin settings.
632 *
633 * @param array $discounts Discounted Amounts.
634 */
635 public function min_max_average( array $discounts ): float {
636 $calculation_type = Settings::get( 'min_max_discount_amount' );
637
638 if ( empty( $discounts ) ) {
639 return 0.00;
640 }
641
642 if ( 'max' === $calculation_type ) {
643 return max( $discounts );
644 }
645
646 return min( $discounts );
647 }
648
649 /**
650 * Strip the embedded tax portion from a discount amount.
651 *
652 * When WooCommerce is set to enter prices inclusive of tax, the discount
653 * amount computed by the intents also carries that tax (e.g. a 50 discount
654 * becomes 55 at 10% tax). The cart fee is added as non-taxable, so the tax
655 * must be removed here to keep the net discount correct.
656 *
657 * When a cart is supplied, the embedded-tax portion is derived from the
658 * cart's own subtotal and subtotal tax rather than assuming the whole
659 * amount carries the standard rate. This keeps mixed carts correct where
660 * some products are taxable and others are not (or use different tax
661 * classes): non-taxable products add to the subtotal but contribute zero
662 * tax, so the effective inclusive-tax ratio drops accordingly.
663 *
664 * @since 1.3.54
665 * @param float $amount Tax-inclusive discount amount.
666 * @param \WC_Cart|null $cart Optional cart used to derive the effective tax ratio.
667 * @return float Tax-exclusive discount amount.
668 */
669 public function get_discount_exclude_tax( float $amount, $cart = null ): float {
670 if ( ! function_exists( 'wc_prices_include_tax' ) || ! wc_prices_include_tax() ) {
671 return $amount;
672 }
673
674 if ( ! class_exists( 'WC_Tax' ) ) {
675 return $amount;
676 }
677
678 // Prefer a product-derived inclusive-tax ratio so mixed carts strip
679 // only the tax actually embedded. The discount base is built from each
680 // product's tax-inclusive price (see CalcFactory::get_price), so we
681 // rebuild the same base excluding tax per product. wc_get_price_excluding_tax
682 // respects each product's own tax status and class: a non-taxable
683 // product returns its price unchanged and contributes zero embedded tax.
684 if ( $cart instanceof \WC_Cart && function_exists( 'wc_get_price_excluding_tax' ) ) {
685 return $this->strip_tax_using_cart_ratio( $amount, $cart );
686 }
687
688 // Fallback: rates for the standard tax class at the customer location.
689 $rates = \WC_Tax::get_rates( '' );
690
691 if ( empty( $rates ) ) {
692 return $amount;
693 }
694
695 // Tax embedded inside the inclusive amount, then remove it.
696 $tax_total = array_sum( \WC_Tax::calc_inclusive_tax( $amount, $rates ) );
697
698 return $amount - $tax_total;
699 }
700
701 /**
702 * Strip embedded tax from a discount using the cart's own inclusive-tax ratio.
703 *
704 * Rebuilds the discount base (sum of tax-inclusive prices) and its
705 * tax-exclusive counterpart per product, then scales the discount by the
706 * excl/incl ratio. Non-taxable products contribute equally to both sums,
707 * so their share keeps no tax stripped.
708 *
709 * @param float $amount Tax-inclusive discount amount.
710 * @param \WC_Cart $cart Cart used to derive the ratio.
711 * @return float Tax-exclusive discount amount.
712 */
713 private function strip_tax_using_cart_ratio( float $amount, \WC_Cart $cart ): float {
714 $incl_sum = 0.0;
715 $excl_sum = 0.0;
716 $cart_items = $cart->get_cart();
717
718 if ( ! is_array( $cart_items ) ) {
719 $cart_items = array();
720 }
721
722 foreach ( $cart_items as $cart_item ) {
723 if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof \WC_Product ) {
724 continue;
725 }
726
727 $product = $cart_item['data'];
728 $qty = (float) ( $cart_item['quantity'] ?? 1 );
729 $price_incl = (float) $product->get_price();
730 $price_excl = (float) wc_get_price_excluding_tax( $product, array( 'price' => $price_incl ) );
731
732 $incl_sum += $price_incl * $qty;
733 $excl_sum += $price_excl * $qty;
734 }
735
736 // No priced items in the cart -> nothing to strip.
737 if ( $incl_sum <= 0 ) {
738 return $amount;
739 }
740
741 $net = $amount * $excl_sum / $incl_sum;
742
743 $decimals = 2;
744
745 if ( function_exists( 'wc_get_price_decimals' ) ) {
746 $decimals = wc_get_price_decimals();
747 }
748
749 return round( $net, $decimals );
750 }
751
752 /**
753 * Total qualifying quantity for recursive BOGO.
754 *
755 * Sums the quantity of every cart item that passes the campaign's
756 * few-products / "all" filter (product_is_applicable) and its conditions
757 * filter (is_filter_passed), excluding products Disco added as free. This is
758 * the combined "buy" quantity a recursive rule scales the free / discount
759 * quantity against, so products excluded by either filter are not counted.
760 *
761 * @param \WC_Cart $cart Cart object.
762 * @param \Disco\App\Utility\Config $campaign Campaign config.
763 */
764 private function get_total_applicable_quantity( $cart, $campaign ): int {
765 $total = 0;
766
767 $cart_items = $cart->get_cart();
768
769 if ( ! is_array( $cart_items ) ) {
770 return $total;
771 }
772
773 foreach ( $cart_items as $item ) {
774 if ( ! empty( $item['is_free_product'] ) ) {
775 continue;
776 }
777
778 $id = $item['product_id'];
779
780 if ( ! empty( $item['variation_id'] ) ) {
781 $id = $item['variation_id'];
782 }
783
784 if ( ! $campaign->product_is_applicable( $id ) ) {
785 continue;
786 }
787
788 $product = wc_get_product( $id );
789
790 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
791 continue;
792 }
793
794 $total += (int) $item['quantity'];
795 }
796
797 return $total;
798 }
799
800 /**
801 * Get Offer Label.
802 *
803 * @param array $rule Discount Rule.
804 */
805 private function get_item_offers( array $rule ): array {
806 $label = '';
807 $quantity = $rule['min'];
808
809 if ( ! empty( $rule['max'] ) ) {
810 $quantity .= ' - ' . $rule['max'];
811 }
812
813 if ( 'free' === $rule['discount_type'] ) {
814 $label = 'Free';
815 }
816
817 if ( 'percent' === $rule['discount_type'] ) {
818 $label = $rule['discount_value'] . '% off';
819 }
820
821 if ( 'fixed' === $rule['discount_type'] ) {
822 $label = wc_price( $rule['discount_value'] ) . ' off';
823 }
824
825 if ( ! empty( $rule['get_quantity'] ) ) {
826 $quantity = $rule['get_quantity'];
827 }
828
829 return array(
830 'quantity' => $quantity,
831 'label' => $label,
832 );
833 }
834
835 /**
836 * Verify Bulk Intent Rules.
837 *
838 * @param int $quantity Item Quantity.
839 * @param array $rule Discount Rules.
840 */
841 private function verify_bulk_rule( int $quantity, array $rule ): bool {
842 $min = abs( $rule['min'] );
843
844 if ( empty( $rule['max'] ) ) {
845 $max = PHP_INT_MAX;
846 } else {
847 $max = abs( $rule['max'] );
848 }
849
850 if ( $quantity >= $min && $quantity <= $max ) {
851 return true;
852 }
853
854 return $quantity >= $max;
855 }
856
857 /**
858 * Verify Bundle Intent Rules.
859 *
860 * @param int $quantity Item Quantity.
861 * @param array $rule Discount Rules.
862 */
863 private function verify_bundle_rule( int $quantity, array $rule ): bool {
864 $rule_basis = abs( $rule['min'] );
865
866 if ( $rule['recursive'] === 'yes' ) {
867 if ( $quantity >= $rule_basis && ( $quantity % $rule_basis === 0 || $quantity > $rule_basis ) ) {
868 return true;
869 }
870 } elseif ( $quantity >= $rule_basis ) {
871 return true;
872 }
873
874 return false;
875 }
876
877 /**
878 * Verify BuyXGetX Intent Rules.
879 *
880 * @param float $quantity Item Quantity.
881 * @param array $item Cart Item.
882 * @param array $rule Discount Rules.
883 */
884 private function verify_buyxgetx_rule( float $quantity, array $item, array $rule ): bool {//phpcs:ignore
885
886 $verified = ( $quantity >= $rule['min'] && $quantity <= $rule['max'] );
887
888 if ( $verified && $rule['recursive'] === 'no' ) {
889 return true;
890 }
891
892 $base_quantity = abs( $rule['min'] );
893
894 if ( $rule['recursive'] === 'yes' && ! isset( $rule['max'] ) ) {
895 if ( $quantity >= $base_quantity && $quantity % $base_quantity === 0 ) {
896 return true;
897 }
898 } elseif ( $quantity >= $base_quantity ) {
899 return true;
900 }
901
902 return false;
903 }
904
905 /**
906 * Verify BuyXGetY Intent Rules.
907 *
908 * @param \Disco\App\Utility\Config $campaign Campaign Config.
909 * @param float $quantity Item Quantity.
910 * @param array $item Cart Item.
911 * @param array $rule Discount Rules.
912 */
913 private function verify_buyxgety_rule( Config $campaign, float $quantity, array $item, array $rule ): bool {
914 if ( ! is_array( $rule['get_ids'] ) ) {
915 return false;
916 }
917
918 $id = $this->get_cart_item_id( $item );
919 $rule_ids = array_column( $rule['get_ids'], 'id' );
920
921 /**
922 * This code use for automatically apply free items rule for all product BOGO campaigns.
923 */
924 if ( $rule['discount_type'] === 'free' && 'all' === $campaign->get_bogo_type() ) {
925 return $this->verify_buyxgetx_rule( $quantity, $item, $rule );
926 }
927
928 /**
929 * This code use for automatically apply free items rule product based BOGO campaigns.
930 */
931 if ( $rule['discount_type'] === 'free' && 'products' === $campaign->get_bogo_type() ) {
932 return $this->verify_xproduct_cart_rule( $campaign, $rule );
933 }
934
935 if ( 'products' === $campaign->get_bogo_type() && in_array( $id, $rule_ids, true ) ) {
936 return $this->verify_xproduct_cart_rule( $campaign, $rule );
937 }
938
939 if ( 'categories' === $campaign->get_bogo_type() && $this->is_in_category( $id, $rule_ids ) ) {
940 return $this->verify_xproduct_cart_rule( $campaign, $rule );
941 }
942
943 return false;
944 }
945
946 /**
947 * Verify XProduct Cart Rule.
948 *
949 * @param \Disco\App\Utility\Config $campaign Campaign Config.
950 * @param array $rule Discount Rules.
951 */
952 private function verify_xproduct_cart_rule( Config $campaign, array $rule ): bool { //phpcs:ignore
953 $cart = WC()->cart->get_cart();
954
955 foreach ( $cart as $item ) {
956 // Skip free products - they must not count towards the BOGO buy quantity,
957 // otherwise granted free items re-qualify as buy-X and escalate the tier.
958 if ( ! empty( $item['is_free_product'] ) ) {
959 continue;
960 }
961
962 $quantity = $item['quantity'];
963 $id = $item['product_id'];
964
965 if ( ! empty( $item['variation_id'] ) ) {
966 $id = $item['variation_id'];
967 }
968
969 if ( $campaign->product_is_applicable( $id ) && $this->verify_buyxgetx_rule( $quantity, $item, $rule ) ) {
970 $product = wc_get_product( $id );
971 $info = array( 'product' => $product );
972
973 if ( ! Helper::is_filter_passed( $campaign, $info ) ) {
974 continue;
975 }
976
977 return true;
978 }
979 }
980
981 return false;
982 }
983
984 /**
985 * Verify YProduct in Cart.
986 *
987 * @param array $rule_ids Rule Product IDs.
988 * @param array $cart Cart.
989 * @param string $bogo_type BOGO Type.
990 * @return bool Returns true if a valid Y product is found in the cart, false otherwise.
991 */
992 private function verify_yproduct_in_cart( array $rule_ids, array $cart, string $bogo_type ): bool { //phpcs:ignore
993 if ( 'categories' === $bogo_type && ! empty( $rule_ids ) ) {
994 foreach ( $cart as $item ) {
995 $id = $item['product_id'];
996
997 if ( ! empty( $item['variation_id'] ) ) {
998 $id = $item['variation_id'];
999 }
1000
1001 if ( ! $this->is_in_category( $id, $rule_ids ) ) {
1002 continue;
1003 }
1004
1005 return true; // Found a valid Y product
1006 }
1007 }
1008
1009 if ( 'products' === $bogo_type && ! empty( $rule_ids ) ) {
1010 foreach ( $cart as $item ) {
1011 $id = $item['product_id'];
1012
1013 if ( ! empty( $item['variation_id'] ) ) {
1014 $id = $item['variation_id'];
1015 }
1016
1017 if ( ! in_array( $id, $rule_ids, true ) ) {
1018 continue;
1019 }
1020
1021 return true; // Found a valid Y product
1022 }
1023 }
1024
1025 return false;
1026 }
1027
1028 /**
1029 * Get Y products from cart.
1030 *
1031 * @param array $rule_ids Rule Product IDs.
1032 * @param array $cart Cart.
1033 * @param string $bogo_type BOGO Type.
1034 */
1035 private function get_y_product( array $rule_ids, array $cart, string $bogo_type ): array { //phpcs:ignore
1036 $y_products = array();
1037
1038 if ( 'categories' === $bogo_type && ! empty( $rule_ids ) ) {
1039 foreach ( $cart as $item ) {
1040 $id = $item['product_id'];
1041
1042 if ( ! empty( $item['variation_id'] ) ) {
1043 $id = $item['variation_id'];
1044 }
1045
1046 if ( ! $this->is_in_category( $id, $rule_ids ) ) {
1047 continue;
1048 }
1049
1050 $y_products[] = $item; // Push the full cart item
1051 }
1052 }
1053
1054 if ( 'products' === $bogo_type && ! empty( $rule_ids ) ) {
1055 foreach ( $cart as $item ) {
1056 $id = $item['product_id'];
1057
1058 if ( ! empty( $item['variation_id'] ) ) {
1059 $id = $item['variation_id'];
1060 }
1061
1062 if ( ! in_array( $id, $rule_ids, true ) ) {
1063 continue;
1064 }
1065
1066 $y_products[] = $item; // Push the full cart item
1067 }
1068 }
1069
1070 return $y_products;
1071 }
1072
1073 }
1074