PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.7
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.7
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.7, at app/Intents/IntentHelper.php

1,692 lines 54.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\QuantityCounter;
19 use Disco\App\Utility\Settings;
20
21 /**
22 * This Class contains all the common function for cart related intents,
23 * and the intents are Cart, Bulk, Bundle and BOGO
24 *
25 * @package Disco
26 * @subpackage Disco\App\Intents
27 * @author Ohidul Islam <wahid0003@gmail.com>
28 * @link https://webappick.com
29 * @license https://opensource.org/licenses/gpl-license.php GNU Public License
30 * @category IntentHelper
31 */
32 trait IntentHelper {//phpcs:ignore
33
34 /**
35 * Disco Prepare Intents.
36 *
37 * @param array $intents Skip Intents.
38 * Either a single intent or an array of intents.
39 * @return array
40 */
41 public function prepare_intents( $intents = [] ) {//phpcs:ignore
42 $campaigns = ( new Campaign )->get_campaigns( '1' );
43
44
45 $new_intents = array();
46
47 $settings = Settings::get();
48
49 if ( empty( $campaigns ) ) {
50 return $new_intents;
51 }
52
53 foreach ( $campaigns as $campaign ) {
54 // Check discount expiration date.
55 if ( ! Helper::is_in_valid_date( $campaign ) ) {
56 continue;
57 }
58
59 // Skip the intent if it is in the skip list.
60 if (
61 ! empty( $intents )
62 && is_array( $intents )
63 && ! in_array( $campaign->discount_intent, $intents, true )
64 ) {
65 continue;
66 }
67
68 if ( $campaign->discount_intent === 'BOGO' ) {
69 $campaign->discount_intent = 'BuyXGetX';
70
71 if ( in_array( $campaign->bogo_type, array( 'products', 'categories' ), true ) ) {
72 $campaign->discount_intent = 'BuyXGetY';
73 }
74 }
75
76 $new_intents[] = IntentFactory::get_intent( $campaign, $settings );
77 }//end foreach
78
79 return $new_intents;
80 }
81
82 /**
83 * Get discount applicable items from the cart.
84 *
85 * @param \WC_Cart $cart Cart Object.
86 * @param \Disco\App\Utility\Config $campaign Campaign Config.
87 * @return array
88 */
89 public function get_items_for_discount( $cart, $campaign ) { //phpcs:ignore
90 $items = array();
91 $discount_type = $campaign->get_discount_intent();
92 $cart_items = $cart->get_cart();
93 $bogo_type = $campaign->get_bogo_type();
94 $y_products = array();
95 $verified_yproducts = false;
96
97 /**
98 * Check if the discount type is BuyXGetY and the Y product is in the cart.
99 * If so, add the Y product to the items array.
100 */
101 if ( $discount_type === 'BuyXGetY' ) {
102 $rule_ids = $campaign->get_rule_product_ids();
103 $y_products = $this->get_y_product( $rule_ids, $cart_items, $bogo_type );
104 $verified_yproducts = $this->verify_yproduct_in_cart( $rule_ids, $cart_items, $bogo_type );
105 }
106
107 foreach ( $cart_items as $item ) {
108 // Skip free products - they should not count towards BOGO buy quantity
109 if ( ! empty( $item['is_free_product'] ) ) {
110 continue;
111 }
112
113 $id = $item['product_id'];
114
115 if ( $item['variation_id'] ) {
116 $id = $item['variation_id'];
117 }
118
119 // Continue if the product is not applicable for the campaign.
120 if ( ! $campaign->product_is_applicable( $id ) ) {
121 continue;
122 }
123
124 // Continue if the item is not passed the filter.
125 $product = wc_get_product( $id );
126
127 $info = array( 'product' => $product );
128
129 if ( ! Helper::is_filter_passed( $campaign, $info ) ) {
130 continue;
131 }
132
133 /**
134 * Check if the discount type is BuyXGetY and the Y product is in the cart.
135 * If so, add the Y product to the items array.
136 * These products not need to be passed the filter.
137 */
138 if ( $discount_type === 'BuyXGetY' && $verified_yproducts ) {
139 $items = array_merge( $items, $y_products );
140 }
141
142 $items[] = $item;
143 }//end foreach
144
145 return $items;
146 }
147
148 /**
149 * Verify the rules.
150 *
151 * @param \Disco\App\Utility\Config $campaign Campaign Config.
152 * @param array $item Cart Item.
153 * @param array $rule Cart Item.
154 * @return bool
155 */
156 public function verify_rules( $campaign, $item, $rule ) {
157 if ( is_object( $rule ) ) {
158 $rule = (array) $rule;
159 }
160
161 $basis = $this->get_basis_for_item( $item );
162
163 if ( empty( $rule ) ) {
164 return false;
165 }
166
167 switch ( $campaign->get_discount_intent() ) {
168 case 'Product':
169 case 'Cart':
170 return isset( $rule['discount_value'], $rule['discount_type'] );
171
172 case 'Bulk':
173 return $this->verify_bulk_rule( $basis, $rule );
174
175 case 'Bundle':
176 return $this->verify_bundle_rule( $basis, $rule );
177
178 case 'BuyXGetX':
179 return $this->verify_buyxgetx_rule( $basis, $item, $rule );
180
181 case 'BuyXGetY':
182 return $this->verify_buyxgety_rule( $campaign, $basis, $item, $rule );
183
184 default:
185 return false;
186 }
187 }
188
189 /**
190 * Get cart item id.
191 *
192 * @param array $item Cart Item.
193 * @return int
194 */
195 public function get_cart_item_id( $item ) {
196 $id = $item['product_id'];
197
198 if ( $item['variation_id'] > 0 ) {
199 $id = $item['variation_id'];
200 }
201
202 return $id;
203 }
204
205 /**
206 * Get the basis for cart.
207 * Returns the quantity or price of the cart.
208 * If the cart is not valid, then return 0.
209 *
210 * @param \Disco\App\Utility\Config $campaign Discount Rules.
211 * @param \WC_Cart $cart Cart .
212 * @return float|int
213 */
214 public function get_basis_for_cart( $campaign, $cart ) {
215 if ( ! $cart instanceof \WC_Cart || $cart->is_empty() ) {
216 return 0;
217 }
218
219 if ( 'cart_quantity' === $campaign->get_discount_based_on() ) {
220 return absint( $cart->get_cart_contents_count() );
221 }
222
223 return abs( $cart->get_subtotal() );
224 }
225
226 /**
227 * Get the basis for item.
228 * Returns the quantity or price of the item.
229 * If the item is not valid, then return 0.
230 *
231 * @param array $item Cart Item.
232 * @return int
233 */
234 public function get_basis_for_item( $item ) {
235 if ( empty( $item['quantity'] ) ) {
236 return 0;
237 }
238
239 return absint( $item['quantity'] );
240 }
241
242 /**
243 * Calculate discount.
244 * Returns the discount amount.
245 *
246 * @param float $cost Item Price or Cart Subtotal.
247 * @param string $discount_type Discount Type.
248 * @param float $discount_value Discount Value.
249 * @return float
250 */
251 public function calculate_discount( $cost, $discount_type, $discount_value ) {
252 $cost = (float) $cost;
253 $discount_value = (float) $discount_value;
254
255 if ( 'percent' === $discount_type ) {
256 $cost = $cost * $discount_value / 100;
257 }
258
259 if ( 'fixed' === $discount_type || 'fixed_per_product' === $discount_type ) {
260 $cost = $discount_value;
261 }
262
263 return $cost;
264 }
265
266 /**
267 * Get the discounted price.
268 *
269 * @param float $cost Item Price or Cart Subtotal.
270 * @param float $discounted_amount Discounted Amount.
271 * @return float Discounted Price.
272 */
273 public function discounted_price( $cost, $discounted_amount ) {
274 $discounted_price = $cost - $discounted_amount;
275
276 if ( $discounted_price < 0 ) {
277 $discounted_price = $cost;
278 }
279
280 return $discounted_price;
281 }
282
283 /**
284 * Get the discounted amount.
285 *
286 * @param float $cost Item Price or Cart Subtotal.
287 * @param float $discounted_amount Discounted Amount.
288 */
289 public function get_amount_after_discount( float $cost, float $discounted_amount ): float {
290 $discounted_amount = $cost - $discounted_amount;
291
292 if ( $discounted_amount < 0 ) {
293 $discounted_amount = $cost;
294 }
295
296 return $discounted_amount;
297 }
298
299 /**
300 * Check if a number is a multiple of another number for recursive discount.
301 *
302 * @param float|int $number Number to check.
303 * @param float|int $of Number to check against.
304 * @return bool Returns true if the number is a multiple of the given number, false otherwise.
305 */
306 public function is_multiple( $number, $of ) {
307 if ( $of >= $number ) {
308 return (float) $of % (float) $number === 0;
309 }
310
311 return false;
312 }
313
314 /**
315 * Check if a product is in a category.
316 *
317 * @param int $product_id Product ID.
318 * @param array $categories Categories.
319 * @return bool
320 */
321 public function is_in_category( $product_id, $categories ) {
322 // Ensure $categories is an array
323 if ( ! is_array( $categories ) ) {
324 $categories = array( $categories );
325 }
326
327 $get_product = wc_get_product( $product_id );
328
329 if ( ! $get_product ) {
330 return false;
331 }
332
333 if ( $get_product->get_type() === 'variation' ) {
334 $product_id = $get_product->get_parent_id();
335 }
336
337 $terms = get_the_terms( $product_id, 'product_cat' );
338
339 if ( ! $terms || is_wp_error( $terms ) ) {
340 return false;
341 }
342
343 $term_ids = array();
344
345 foreach ( $terms as $term ) {
346 $term_ids[] = $term->term_id;
347
348 // Include all parent category IDs
349 $parent_ids = get_ancestors( $term->term_id, 'product_cat' );
350
351 if ( empty( $parent_ids ) ) {
352 continue;
353 }
354
355 $term_ids = array_merge( $term_ids, $parent_ids );
356 }
357
358 // Remove duplicates just in case
359 $term_ids = array_unique( $term_ids );
360 $common_values = array_intersect( $term_ids, $categories );
361
362 return ! empty( $common_values );
363 }
364
365 /**
366 * Prepare a discount array for cart.
367 * Returns the discount array.
368 *
369 * @param \Disco\App\Utility\Config $campaign Campaign Config.
370 * @param array $items Cart Item Object.
371 * @param \WC_Cart $cart Cart Object.
372 */
373 public function get_item_discounts( Config $campaign, array $items, \WC_Cart $cart ): array {//phpcs:ignore
374 $discounts = array();
375 // $cart = WC()->cart;
376 // Get the discount rules.
377 $rules = $campaign->get_discount_rules();
378
379 // Check if the cart is not empty.
380 if ( empty( $items ) ) {
381 return $discounts;
382 }
383
384 if ( ! is_array( $rules ) || empty( $rules ) ) {
385 return $discounts;
386 }
387
388 /**
389 * BuyXGetY tiers are mutually exclusive: only one rule applies per
390 * campaign, selected by the buy quantity. Without this, tiers rewarding
391 * different get-products (e.g. rule 1 → product A, rule 2 → product B)
392 * would all fire and stack. Every other intent already collapses
393 * overlapping rules per product via min_max_average.
394 *
395 * Exception: a free products BOGO in `separate` mode is evaluated
396 * per-product (each cart product uses the tier(s) its own quantity
397 * matches), so all rules are kept and the reward is summed downstream.
398 */
399 if ( 'BuyXGetY' === $campaign->get_discount_intent() && ! $this->is_per_product_free_bogo( $campaign, $rules ) ) {
400 $rules = $this->select_bogo_tier_rule( $campaign, $rules );
401
402 if ( empty( $rules ) ) {
403 return $discounts;
404 }
405 }
406
407 /**
408 * Combined qualifying quantity for recursive BOGO, filtered by the
409 * conditions and few-products filters and excluding Disco free items.
410 */
411 $total_applicable_qty = $this->get_total_applicable_quantity( $cart, $campaign );
412
413 /**
414 * "Count Quantity As" combined / variations modes pool quantities across
415 * cart line items before deciding eligibility, so they take a dedicated
416 * path. The default `separate` mode keeps the original per-line behaviour.
417 *
418 * BOGO is excluded — count modes only apply to Bundle / Bulk:
419 * - BuyXGetX matches purely on each line's own quantity, so it always
420 * behaves as `separate` regardless of the count mode.
421 * - BuyXGetY rewards the Y (get) product, not the counted X products, so
422 * it stays on the per-item path and only its buy-X gate is pooled
423 * (see {@see self::verify_xproduct_cart_rule()}).
424 */
425 if (
426 ! in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true )
427 && in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true )
428 ) {
429 return $this->get_grouped_item_discounts( $campaign, $cart, $rules, $total_applicable_qty );
430 }
431
432 // Loop through the cart items.
433 foreach ( $items as $item ) {
434 // Loop through the discount rules.
435 foreach ( $rules as $rule ) {
436 if ( is_object( $rule ) ) {
437 $rule = (array) $rule;
438 }
439
440 // Verify the rule by campaign settings.
441 if ( ! $this->verify_rules( $campaign, $item, $rule ) ) {
442 continue;
443 }
444
445 $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
446 }//end foreach
447 }//end foreach
448
449 return $discounts;
450 }
451
452 /**
453 * Prepare discounts for the `combined` / `variations` counting modes.
454 *
455 * Eligibility is resolved by {@see QuantityCounter}: quantities are pooled
456 * per group (all applicable lines for `combined`, per parent product for
457 * `variations`), range-capped, and threaded into the Calc layer through the
458 * `disco_forced_qty` item key.
459 *
460 * How many units are actually discounted depends on the rule:
461 * - Bundle / Bulk (no `get_quantity`) → every qualifying unit is discounted.
462 * - BOGO (`get_quantity` set) → only the reward units are discounted,
463 * i.e. `get_quantity × bundles`, where `bundles = floor(pool / min)`
464 * (1 for a non-recursive rule). Otherwise a "buy 2 get 1" in combined mode
465 * would wrongly discount the 2 bought units instead of the 1 reward unit.
466 * Reward units are handed out across the eligible lines in cart order.
467 *
468 * @param \Disco\App\Utility\Config $campaign Campaign Config.
469 * @param \WC_Cart $cart Cart Object.
470 * @param array $rules Discount rules.
471 * @param int $total_applicable_qty Combined qualifying quantity.
472 */
473 private function get_grouped_item_discounts( Config $campaign, \WC_Cart $cart, array $rules, int $total_applicable_qty ): array {//phpcs:ignore
474 $discounts = array();
475 $quantity_counter = new QuantityCounter;
476
477 foreach ( $rules as $rule ) {
478 if ( is_object( $rule ) ) {
479 $rule = (array) $rule;
480 }
481
482 $eligible_lines = $quantity_counter->get_eligible_units( $cart, $campaign, $rule );
483
484 if ( empty( $eligible_lines ) ) {
485 continue;
486 }
487
488 // Total qualifying units across the pooled lines.
489 $total_qualifying_units = 0;
490
491 foreach ( $eligible_lines as $eligible_line ) {
492 $total_qualifying_units += $eligible_line['eligible_qty'];
493 }
494
495 $discountable_units = $this->grouped_discount_budget( $rule, $total_qualifying_units );
496
497 // Hand out the discountable units across the eligible lines in cart order.
498 foreach ( $eligible_lines as $eligible_line ) {
499 if ( $discountable_units <= 0 ) {
500 break;
501 }
502
503 $line_units = (int) min( $eligible_line['eligible_qty'], $discountable_units );
504
505 if ( $line_units <= 0 ) {
506 continue;
507 }
508
509 $discountable_units -= $line_units;
510
511 $item = $eligible_line['item'];
512 $item['disco_forced_qty'] = $line_units;
513
514 $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
515 }
516 }
517
518 return $discounts;
519 }
520
521 /**
522 * Number of units a pooled rule should actually discount.
523 *
524 * BOGO rules (with `get_quantity`) reward `get_quantity × bundles`; every
525 * other rule discounts the whole qualifying pool.
526 *
527 * @param array $rule Discount rule.
528 * @param int $total_qualifying Pooled qualifying quantity.
529 */
530 private function grouped_discount_budget( array $rule, int $total_qualifying ): int {
531 $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
532
533 if ( $reward_quantity <= 0 ) {
534 // Bundle / Bulk: discount every qualifying unit.
535 return $total_qualifying;
536 }
537
538 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
539 $bundle_count = $minimum_quantity > 0 ? (int) floor( $total_qualifying / $minimum_quantity ) : 1; // phpcs:ignore
540
541 if ( $bundle_count < 1 ) {
542 $bundle_count = 1;
543 }
544
545 // BOGO reward: get_quantity per qualifying bundle, never more than the pool.
546 return (int) min( $reward_quantity * $bundle_count, $total_qualifying );
547 }
548
549 /**
550 * Populate the discount array for a single cart line + rule pair.
551 *
552 * Shared by both the per-line (`separate`) path and the grouped
553 * (`combined` / `variations`) path so the emitted discount structure is
554 * identical regardless of counting mode.
555 *
556 * @param array $discounts Discount array, by reference.
557 * @param \Disco\App\Utility\Config $campaign Campaign Config.
558 * @param array $rule Single discount rule.
559 * @param array $item Cart item (may carry `disco_forced_qty`).
560 * @param \WC_Cart $cart Cart Object.
561 * @param int $total_applicable_qty Combined qualifying quantity.
562 */
563 private function apply_rule_to_discount( array &$discounts, Config $campaign, array $rule, array $item, \WC_Cart $cart, int $total_applicable_qty ): void {//phpcs:ignore
564 $product = $item['data'];
565 $rule_key = md5( microtime() . wp_rand() );
566 $effective_product_id = $this->get_cart_item_id( $item );
567
568 // Set the discount array.
569 $discounts[ $effective_product_id ]['original_price'] = $product->get_price();
570 $discounts[ $effective_product_id ]['cart_item_key'] = $item['key'];
571
572 // Set discount applies to
573 $discounts[ $effective_product_id ]['discount_applies_to'][ $rule_key ] = CalcFactory::discount_applies_to( $rule, $campaign );
574
575 // Set Discounts.
576 if ( $rule['discount_type'] === 'free' ) {
577 $discounted_amount = array(
578 'price' => $product->get_price(),
579 'discount' => 0,
580 'line_subtotal' => $product->get_price() * $item['quantity'],
581 'discounted_quantities' => 0,
582 );
583 $discounts[ $effective_product_id ]['free'] = true;
584 $discounts[ $effective_product_id ]['get_ids'] = $rule['get_ids'];
585
586 if ( 'products' === $campaign->get_bogo_type() ) {
587 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
588 /**
589 * Combined / variations: the buy-X quantity is pooled across
590 * the cart, so the reward is one bundle (non-recursive) or one
591 * per bundle (recursive) of the pooled total — not per line.
592 */
593 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
594 $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
595
596 if ( 'yes' === $rule['recursive'] && $minimum_quantity > 0 ) {
597 $discounts[ $effective_product_id ]['get_qty'] = (int) floor( $total_applicable_qty / $minimum_quantity ) * $reward_quantity;
598 } else {
599 $discounts[ $effective_product_id ]['get_qty'] = $reward_quantity;
600 }
601 } else {
602 /**
603 * Separate: X = all products. Every cart product grants the
604 * reward of each tier its own quantity matches (summed), so
605 * two products matching different tiers each contribute.
606 */
607 $discounts[ $effective_product_id ]['get_qty'] = $this->get_products_bogo_free_quantity( $campaign, $cart );
608 }
609 } else {
610 /**
611 * BuyXGetX ('all'): per line, recursive → floor( line qty / min ) ×
612 * get_quantity. Category BOGO never reaches this — its free items are
613 * owned by {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}.
614 */
615 $discounts[ $effective_product_id ]['get_qty'] = CalcFactory::get_free_quantity( $rule, $item );
616 }
617 } else {
618 $discounted_amount = CalcFactory::get_discount( $rule, $item, $cart, $campaign );
619 $discounts[ $effective_product_id ]['free'] = false;
620 $discounts[ $effective_product_id ]['get_ids'] = array();
621 $discounts[ $effective_product_id ]['get_qty'] = 0;
622 }
623
624 $discounts[ $effective_product_id ]['discount_types'][ $rule_key ] = $rule['discount_type'];
625 $discounts[ $effective_product_id ]['intent'][ $rule_key ] = $campaign->get_discount_intent();
626 $discounts[ $effective_product_id ]['prices'][ $rule_key ] = $discounted_amount['price'];
627 $discounts[ $effective_product_id ]['discounts'][ $rule_key ] = $discounted_amount['discount'];
628 $discounts[ $effective_product_id ]['subtotals'][ $rule_key ] = $discounted_amount['line_subtotal'];
629 $discounts[ $effective_product_id ]['discounted_quantities'][ $rule_key ] = $discounted_amount['discounted_quantities'];
630 $discounts[ $effective_product_id ]['quantities'][ $rule_key ] = $item['quantity'];
631
632 $discounts[ $effective_product_id ]['offers'][ $rule_key ] = $this->get_item_offers( $rule );
633 }
634
635 /**
636 * Prepare discounts for cart items.
637 *
638 * @param array $intents Intents.
639 * @param \WC_Cart $cart Cart.
640 * @return array|false
641 */
642 public function prepare_item_discounts( array $intents, \WC_Cart $cart ) { //phpcs:ignore
643 if ( empty( $intents ) ) {
644 return false;
645 }
646
647 $discounts = array();
648
649 // Loop through the intents.
650 foreach ( $intents as $intent ) {
651 /**
652 * Get a discount limit form campaign.
653 *
654 * Compare with total product meta and apply discount
655 */
656 $discount_limit = $intent->campaign->discount_max_user;
657
658 // Only count applied orders when a limit is actually configured.
659 if ( ! empty( $discount_limit ) && $discount_limit >= 0 ) {
660 $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
661
662 if ( $total_applied_campaign >= $discount_limit ) {
663 continue;
664 }
665 }
666
667 $items = $this->get_items_for_discount( $cart, $intent->campaign );
668 $get_discounts = $intent->get_discounts( $items, $cart );
669
670 if ( empty( $get_discounts ) ) {
671 continue;
672 }
673
674 // Only stage campaigns whose discount actually applied to the cart.
675 ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id );
676
677 foreach ( $get_discounts as $item_id => $discount ) {
678 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
679 }
680 }
681
682 // Check if the discounts are empty.
683 if ( empty( $discounts ) ) {
684 return false;
685 }
686
687 // Get the min or max discount amount for each item.
688 foreach ( $discounts as $item_id => $discount ) {
689 $discounts[ $item_id ] = $this->min_max_average( $discount['discounts'] );
690 }
691
692 return $discounts;
693 }
694
695 /**
696 * Get valid BOGO category item ID.
697 *
698 * @param array $intents Intents.
699 * @param \WC_Cart $cart Cart.
700 * @param array $discounts Discounts.
701 * @return int|string|null
702 */
703 public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {//phpcs:ignore
704 foreach ( $intents as $intent ) {
705 if (
706 $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
707 $intent->campaign->get_bogo_type() !== 'categories'
708 ) {
709 continue;
710 }
711
712 // Collect every eligible category item (in cart order) with its price,
713 // then pick the reward item per the campaign's selection strategy.
714 $candidates = array();
715
716 foreach ( $cart->get_cart() as $item ) {
717 $cart_item_id = $this->get_cart_item_id( $item );
718
719 if ( isset( $discounts[ $cart_item_id ] ) && $discounts[ $cart_item_id ] > 0 ) { // phpcs:ignore
720 $candidates[ $cart_item_id ] = CalcFactory::get_price( $item );
721 }
722 }
723
724 if ( empty( $candidates ) ) {
725 continue;
726 }
727
728 return $this->pick_reward_item( $candidates, $intent->campaign->get_free_item_selection() );
729 }
730
731 return null;
732 }
733
734 /**
735 * Pick the reward cart-item id from eligible candidates.
736 *
737 * Honours the "Free Item Selection" strategy when more than one product
738 * from the reward category is in the cart:
739 * - `cart_order` (default) → the first eligible item in cart order.
740 * - `lowest` → the lowest-priced eligible item.
741 * - `highest` → the highest-priced eligible item.
742 * Ties keep cart order (first match wins).
743 *
744 * @param array<int|string, float> $candidates Map of item id => price, in cart order.
745 * @param string $selection Selection strategy.
746 * @return int|string|null
747 */
748 private function pick_reward_item( array $candidates, string $selection ) {
749 if ( 'lowest' === $selection ) {
750 return array_keys( $candidates, min( $candidates ), true )[0];
751 }
752
753 if ( 'highest' === $selection ) {
754 return array_keys( $candidates, max( $candidates ), true )[0];
755 }
756
757 return array_key_first( $candidates );
758 }
759
760 /**
761 * Prepare discounts for cart items.
762 *
763 * @param array $intents Intents.
764 * @param \WC_Cart $cart Cart.
765 * @return array|false
766 */
767 public function prepare_item_discounts_bogo_free( array $intents, \WC_Cart $cart ) { //phpcs:ignore
768 if ( empty( $intents ) ) {
769 return false;
770 }
771
772 $discounts = array();
773
774 // Loop through the intents.
775 foreach ( $intents as $intent ) {
776 /**
777 * Get a discount limit form campaign.
778 *
779 * Compare with total product meta and apply discount
780 */
781 $discount_limit = $intent->campaign->discount_max_user;
782
783 // Only count applied orders when a limit is actually configured.
784 if ( ! empty( $discount_limit ) && $discount_limit >= 0 ) {
785 $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
786
787 if ( $total_applied_campaign >= $discount_limit ) {
788 continue;
789 }
790 }
791
792 $items = $this->get_items_for_discount( $cart, $intent->campaign );
793 $get_discounts = $intent->get_discounts( $items, $cart );
794
795 if ( empty( $get_discounts ) ) {
796 continue;
797 }
798
799 // Only stage campaigns whose discount actually applied to the cart.
800 ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id );
801
802 foreach ( $get_discounts as $item_id => $discount ) {
803 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); // phpcs:ignore
804 $discounts[ $item_id ]['free'] = $discount['free']; // phpcs:ignore
805 $discounts[ $item_id ]['get_ids'] = $discount['get_ids']; // phpcs:ignore
806 $discounts[ $item_id ]['get_qty'] = $discount['get_qty']; // phpcs:ignore
807 $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type(); // phpcs:ignore
808 $discounts[ $item_id ]['free_item_selection'] = $intent->campaign->get_free_item_selection(); // phpcs:ignore
809 }
810 }
811
812 // Check if the discounts are empty.
813 if ( empty( $discounts ) ) {
814 return false;
815 }
816
817 /**
818 * Aggregate the per-item free rewards into the flat shape the cart hook
819 * consumes. Union every qualifying reward id (previously this overwrote
820 * on each pass, so only the last cart item was ever granted its free
821 * product) and keep a per-id quantity map for products whose free counts
822 * differ (e.g. recursive BuyXGetX).
823 */
824 $reward_product_ids = array();
825 $reward_quantity_map = array();
826 $has_free_items = false;
827 $bogo_type = 'products';
828 $free_item_selection = 'cart_order';
829
830 foreach ( $discounts as $item_id => $discount ) {
831 $has_free_items = $discount['free'];
832 $bogo_type = $discount['bogo_type'] ?? 'products';
833 $free_item_selection = $discount['free_item_selection'] ?? 'cart_order';
834
835 $rule_reward_ids = ! empty( $discount['get_ids'] ) ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); // phpcs:ignore
836
837 foreach ( $rule_reward_ids as $reward_product_id ) {
838 $reward_product_id = (int) $reward_product_id; // phpcs:ignore
839 $reward_product_ids[] = $reward_product_id; // phpcs:ignore
840 $reward_quantity_map[ $reward_product_id ] = (int) $discount['get_qty']; // phpcs:ignore
841 }
842 }
843
844 $reward_product_ids = array_values( array_unique( $reward_product_ids ) );
845
846 $discounts['discount'] = 0.0;
847 $discounts['free'] = $has_free_items;
848 $discounts['get_ids'] = $reward_product_ids;
849 $discounts['get_qty'] = empty( $reward_quantity_map ) ? 0 : max( $reward_quantity_map ); // phpcs:ignore
850 $discounts['get_qty_map'] = $reward_quantity_map;
851 $discounts['bogo_type'] = $bogo_type;
852 $discounts['free_item_selection'] = $free_item_selection;
853
854 /**
855 * BuyXGetY (products): replace the flat aggregation with an authoritative
856 * reward map keyed by the tier's own get product, so each cart product
857 * that qualifies grants its tier's reward.
858 *
859 * Category BOGO is not handled here — {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}
860 * owns its reward selection, buy reservation and cart reconciliation.
861 */
862 foreach ( $intents as $intent ) {
863 $bogo_type = $intent->campaign->get_bogo_type();
864
865 if ( 'BuyXGetY' !== $intent->campaign->get_discount_intent() || 'products' !== $bogo_type ) {
866 continue;
867 }
868
869 // Only free-type rules grant free items; a percent/fixed BOGO must not
870 // fabricate free products here.
871 $rules = $intent->campaign->get_discount_rules();
872 $first_rule = ( is_array( $rules ) && isset( $rules[0] ) ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
873
874 if ( ! isset( $first_rule['discount_type'] ) || 'free' !== $first_rule['discount_type'] ) {
875 continue;
876 }
877
878 $reward_map = $this->compute_bogo_products_free_map( $intent->campaign, $cart );
879
880 // Always authoritative: an empty map means no qualifying (non-reward)
881 // purchase, so no free items — do not fall back to the flat count.
882 $discounts['get_ids'] = array_keys( $reward_map ); // phpcs:ignore
883 $discounts['get_qty_map'] = $reward_map; // phpcs:ignore
884 // Each get-product is freed its own quantity.
885 $discounts['get_qty'] = empty( $reward_map ) ? 0 : max( $reward_map ); // phpcs:ignore
886 $discounts['free'] = ! empty( $reward_map ); // phpcs:ignore
887 $discounts['bogo_type'] = $bogo_type;
888 $discounts['free_item_selection'] = $intent->campaign->get_free_item_selection();
889 }
890
891 return $discounts;
892 }
893
894 /**
895 * Authoritative free Y map for a BuyXGetY (products) campaign.
896 *
897 * Returns get-product id => free quantity, attributing each tier's reward to
898 * that tier's own get-products:
899 * - `separate` → each cart line credits every tier whose range
900 * its own quantity matches ([min,max]); rewards
901 * sum, and each tier credits its own get-products.
902 * - `combined` → one pool over every applicable line; the highest
903 * tier it reaches credits its get-products once.
904 * - `variations` → one pool per parent product (all variations of
905 * the same variable count together, and a simple
906 * product is its own pool); every pool that
907 * reaches a tier credits that tier's get-products,
908 * so two qualifying parents grant two rewards.
909 * Recursive tiers multiply by floor(qty/min) (line qty for separate, pool qty
910 * for combined / variations).
911 *
912 * @param \Disco\App\Utility\Config $campaign Campaign Config.
913 * @param \WC_Cart $cart Cart Object.
914 * @return array<int, int>
915 */
916 private function compute_bogo_products_free_map( Config $campaign, \WC_Cart $cart ): array {//phpcs:ignore
917 $rules = $campaign->get_discount_rules();
918
919 if ( ! is_array( $rules ) || empty( $rules ) ) {
920 return array();
921 }
922
923 $reward_map = array();
924
925 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
926 foreach ( $this->get_bogo_buy_pool_quantities( $cart, $campaign ) as $pool_quantity ) {
927 $this->credit_bogo_pool_reward( $reward_map, $rules, $pool_quantity );
928 }
929
930 return $reward_map;
931 }
932
933 /**
934 * Separate: build the buy quantity per product, then each product earns
935 * the highest tier its quantity qualifies for (min <= qty). The upper
936 * `max` marks where the next tier begins, not a cut-off, so a quantity
937 * above the top tier still earns it and a gap earns the tier below.
938 * Free lines never count.
939 */
940 $buy_quantity_per_product = array();
941
942 foreach ( $cart->get_cart() as $item ) {
943 if ( ! empty( $item['is_free_product'] ) ) {
944 continue;
945 }
946
947 $effective_product_id = $item['product_id'];
948
949 if ( ! empty( $item['variation_id'] ) ) {
950 $effective_product_id = $item['variation_id'];
951 }
952
953 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
954 continue;
955 }
956
957 $product = wc_get_product( $effective_product_id );
958
959 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
960 continue;
961 }
962
963 $buy_quantity_per_product[ (int) $effective_product_id ] = ( $buy_quantity_per_product[ (int) $effective_product_id ] ?? 0 ) + (int) $item['quantity'];
964 }
965
966 foreach ( $buy_quantity_per_product as $quantity ) {
967 $qualifying_rule = null;
968 $qualifying_minimum = -1;
969
970 foreach ( $rules as $rule ) {
971 $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
972 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
973 $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
974
975 if ( $minimum_quantity <= 0 || $reward_quantity <= 0 || empty( $rule['get_ids'] ) ) {
976 continue;
977 }
978
979 if ( $quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
980 $qualifying_rule = $rule;
981 $qualifying_minimum = $minimum_quantity;
982 }
983 }
984
985 if ( null === $qualifying_rule ) {
986 continue;
987 }
988
989 $minimum_quantity = absint( $qualifying_rule['min'] );
990 $reward_quantity = (int) $qualifying_rule['get_quantity'];
991 $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
992 $reward_total = ( $is_recursive && $minimum_quantity > 0 ) ? ( (int) floor( $quantity / $minimum_quantity ) * $reward_quantity ) : $reward_quantity; // phpcs:ignore
993
994 if ( $reward_total > 0 ) { // phpcs:ignore
995 foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
996 $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $reward_total;
997 }
998 }
999 }
1000
1001 return $reward_map;
1002 }
1003
1004 /**
1005 * Pooled buy quantities for combined / variations, excluding free lines.
1006 *
1007 * `combined` returns a single pool over every applicable line. `variations`
1008 * returns one pool per parent product, so variations of the same variable
1009 * product count together while different parents stay apart — the same
1010 * grouping {@see QuantityCounter} applies to the buy-X gate.
1011 *
1012 * @param \WC_Cart $cart Cart Object.
1013 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1014 * @return array<string, int> Pool key => pooled quantity.
1015 */
1016 private function get_bogo_buy_pool_quantities( \WC_Cart $cart, Config $campaign ): array {//phpcs:ignore
1017 $pool_quantities = array();
1018 $pool_per_parent = 'variations' === $campaign->get_count_quantity_as();
1019 $cart_items = $cart->get_cart();
1020
1021 if ( ! is_array( $cart_items ) ) {
1022 return $pool_quantities;
1023 }
1024
1025 foreach ( $cart_items as $item ) {
1026 if ( ! empty( $item['is_free_product'] ) ) {
1027 continue;
1028 }
1029
1030 $effective_product_id = $item['product_id'];
1031
1032 if ( ! empty( $item['variation_id'] ) ) {
1033 $effective_product_id = $item['variation_id'];
1034 }
1035
1036 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1037 continue;
1038 }
1039
1040 $product = wc_get_product( $effective_product_id );
1041
1042 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1043 continue;
1044 }
1045
1046 $pool_key = $pool_per_parent ? 'parent_' . (int) $item['product_id'] : 'all'; // phpcs:ignore
1047
1048 $pool_quantities[ $pool_key ] = ( $pool_quantities[ $pool_key ] ?? 0 ) + (int) $item['quantity'];
1049 }
1050
1051 return $pool_quantities;
1052 }
1053
1054 /**
1055 * Credit one pool's reward to the get-products of the tier it qualifies for.
1056 *
1057 * The winning tier is the one with the highest `min` the pool reaches; a
1058 * recursive tier multiplies its reward by the number of complete sets.
1059 *
1060 * @param array $reward_map Reward map (get-product id => quantity), by reference.
1061 * @param array $rules Discount rules.
1062 * @param int $pool_quantity Pooled buy quantity.
1063 */
1064 private function credit_bogo_pool_reward( array &$reward_map, array $rules, int $pool_quantity ): void {//phpcs:ignore
1065 $qualifying_rule = null;
1066 $qualifying_minimum = -1;
1067
1068 foreach ( $rules as $rule ) {
1069 $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1070 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1071
1072 if ( $minimum_quantity > 0 && $pool_quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1073 $qualifying_rule = $rule;
1074 $qualifying_minimum = $minimum_quantity;
1075 }
1076 }
1077
1078 if ( null === $qualifying_rule || empty( $qualifying_rule['get_ids'] ) ) {
1079 return;
1080 }
1081
1082 $minimum_quantity = absint( $qualifying_rule['min'] );
1083 $reward_quantity = ! empty( $qualifying_rule['get_quantity'] ) ? (int) $qualifying_rule['get_quantity'] : 0; // phpcs:ignore
1084 $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1085 $quantity = ( $is_recursive && $minimum_quantity > 0 ) ? (int) floor( $pool_quantity / $minimum_quantity ) * $reward_quantity : $reward_quantity; // phpcs:ignore
1086
1087 if ( $quantity <= 0 ) {
1088 return;
1089 }
1090
1091 foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1092 $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $quantity;
1093 }
1094 }
1095
1096 /**
1097 * Is it a bogo campaign?
1098 *
1099 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1100 */
1101 public function is_bogo( Config $campaign ): bool { // phpcs:ignore
1102 return in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true );
1103 }
1104
1105 /**
1106 * Check cart is valid or not
1107 */
1108 public function cart_is_valid(): bool {
1109 return WC()->cart instanceof \WC_Cart && ! WC()->cart->is_empty();
1110 }
1111
1112 /**
1113 * Get min or max price from an array discounted prices according to plugin settings.
1114 *
1115 * @param array $discounts Discounted Amounts.
1116 */
1117 public function min_max_average( array $discounts ): float {
1118 $calculation_type = Settings::get( 'min_max_discount_amount' );
1119
1120 if ( empty( $discounts ) ) {
1121 return 0.00;
1122 }
1123
1124 if ( 'max' === $calculation_type ) {
1125 return max( $discounts );
1126 }
1127
1128 return min( $discounts );
1129 }
1130
1131 /**
1132 * Strip the embedded tax portion from a discount amount.
1133 *
1134 * When WooCommerce is set to enter prices inclusive of tax, the discount
1135 * amount computed by the intents also carries that tax (e.g. a 50 discount
1136 * becomes 55 at 10% tax). The cart fee is added as non-taxable, so the tax
1137 * must be removed here to keep the net discount correct.
1138 *
1139 * When a cart is supplied, the embedded-tax portion is derived from the
1140 * cart's own subtotal and subtotal tax rather than assuming the whole
1141 * amount carries the standard rate. This keeps mixed carts correct where
1142 * some products are taxable and others are not (or use different tax
1143 * classes): non-taxable products add to the subtotal but contribute zero
1144 * tax, so the effective inclusive-tax ratio drops accordingly.
1145 *
1146 * @since 1.3.54
1147 * @param float $amount Tax-inclusive discount amount.
1148 * @param \WC_Cart|null $cart Optional cart used to derive the effective tax ratio.
1149 * @return float Tax-exclusive discount amount.
1150 */
1151 public function get_discount_exclude_tax( float $amount, $cart = null ): float {
1152 if ( ! function_exists( 'wc_prices_include_tax' ) || ! wc_prices_include_tax() ) {
1153 return $amount;
1154 }
1155
1156 if ( ! class_exists( 'WC_Tax' ) ) {
1157 return $amount;
1158 }
1159
1160 // Prefer a product-derived inclusive-tax ratio so mixed carts strip
1161 // only the tax actually embedded. The discount base is built from each
1162 // product's tax-inclusive price (see CalcFactory::get_price), so we
1163 // rebuild the same base excluding tax per product. wc_get_price_excluding_tax
1164 // respects each product's own tax status and class: a non-taxable
1165 // product returns its price unchanged and contributes zero embedded tax.
1166 if ( $cart instanceof \WC_Cart && function_exists( 'wc_get_price_excluding_tax' ) ) {
1167 return $this->strip_tax_using_cart_ratio( $amount, $cart );
1168 }
1169
1170 // Fallback: rates for the standard tax class at the customer location.
1171 $rates = \WC_Tax::get_rates( '' );
1172
1173 if ( empty( $rates ) ) {
1174 return $amount;
1175 }
1176
1177 // Tax embedded inside the inclusive amount, then remove it.
1178 $tax_total = array_sum( \WC_Tax::calc_inclusive_tax( $amount, $rates ) );
1179
1180 return $amount - $tax_total;
1181 }
1182
1183 /**
1184 * Strip embedded tax from a discount using the cart's own inclusive-tax ratio.
1185 *
1186 * Rebuilds the discount base (sum of tax-inclusive prices) and its
1187 * tax-exclusive counterpart per product, then scales the discount by the
1188 * excl/incl ratio. Non-taxable products contribute equally to both sums,
1189 * so their share keeps no tax stripped.
1190 *
1191 * @param float $amount Tax-inclusive discount amount.
1192 * @param \WC_Cart $cart Cart used to derive the ratio.
1193 * @return float Tax-exclusive discount amount.
1194 */
1195 private function strip_tax_using_cart_ratio( float $amount, \WC_Cart $cart ): float {
1196 $incl_sum = 0.0;
1197 $excl_sum = 0.0;
1198 $cart_items = $cart->get_cart();
1199
1200 if ( ! is_array( $cart_items ) ) {
1201 $cart_items = array();
1202 }
1203
1204 foreach ( $cart_items as $cart_item ) {
1205 if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof \WC_Product ) {
1206 continue;
1207 }
1208
1209 $product = $cart_item['data'];
1210 $qty = (float) ( $cart_item['quantity'] ?? 1 );
1211 $price_incl = (float) $product->get_price();
1212 $price_excl = (float) wc_get_price_excluding_tax( $product, array( 'price' => $price_incl ) );
1213
1214 $incl_sum += $price_incl * $qty;
1215 $excl_sum += $price_excl * $qty;
1216 }
1217
1218 // No priced items in the cart -> nothing to strip.
1219 if ( $incl_sum <= 0 ) {
1220 return $amount;
1221 }
1222
1223 $net = $amount * $excl_sum / $incl_sum;
1224
1225 $decimals = 2;
1226
1227 if ( function_exists( 'wc_get_price_decimals' ) ) {
1228 $decimals = wc_get_price_decimals();
1229 }
1230
1231 return round( $net, $decimals );
1232 }
1233
1234 /**
1235 * Total qualifying quantity for recursive BOGO.
1236 *
1237 * Sums the quantity of every cart item that passes the campaign's
1238 * few-products / "all" filter (product_is_applicable) and its conditions
1239 * filter (is_filter_passed), excluding products Disco added as free. This is
1240 * the combined "buy" quantity a recursive rule scales the free / discount
1241 * quantity against, so products excluded by either filter are not counted.
1242 *
1243 * @param \WC_Cart $cart Cart object.
1244 * @param \Disco\App\Utility\Config $campaign Campaign config.
1245 */
1246 private function get_total_applicable_quantity( $cart, $campaign ): int {
1247 $total = 0;
1248
1249 $cart_items = $cart->get_cart();
1250
1251 if ( ! is_array( $cart_items ) ) {
1252 return $total;
1253 }
1254
1255 foreach ( $cart_items as $item ) {
1256 if ( ! empty( $item['is_free_product'] ) ) {
1257 continue;
1258 }
1259
1260 $id = $item['product_id'];
1261
1262 if ( ! empty( $item['variation_id'] ) ) {
1263 $id = $item['variation_id'];
1264 }
1265
1266 if ( ! $campaign->product_is_applicable( $id ) ) {
1267 continue;
1268 }
1269
1270 $product = wc_get_product( $id );
1271
1272 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1273 continue;
1274 }
1275
1276 $total += (int) $item['quantity'];
1277 }
1278
1279 return $total;
1280 }
1281
1282 /**
1283 * Whether a campaign is a free products BOGO evaluated per product.
1284 *
1285 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1286 * @param array $rules Discount rules.
1287 */
1288 private function is_per_product_free_bogo( Config $campaign, array $rules ): bool {
1289 if ( 'products' !== $campaign->get_bogo_type() ) {
1290 return false;
1291 }
1292
1293 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
1294 return false;
1295 }
1296
1297 $first_rule = isset( $rules[0] ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
1298
1299 return isset( $first_rule['discount_type'] ) && 'free' === $first_rule['discount_type'];
1300 }
1301
1302 /**
1303 * Total free Y quantity for a products BuyXGetY campaign in `separate` mode.
1304 *
1305 * X is every applicable product. Each cart line grants the reward of every
1306 * rule (tier) whose range its own quantity falls in — `qty` within
1307 * `[min, max]` (no max = min-only) — summed across matching tiers, and ×
1308 * floor(qty/min) when the tier is recursive. The Y product counts as an X
1309 * line too. Summing across all qualifying lines gives the free Y count, so
1310 * two products each matching a different tier grant each tier's reward.
1311 *
1312 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1313 * @param \WC_Cart $cart Cart Object.
1314 */
1315 private function get_products_bogo_free_quantity( Config $campaign, \WC_Cart $cart ): int {//phpcs:ignore
1316 $rules = $campaign->get_discount_rules();
1317 $cart_items = $cart->get_cart();
1318
1319 if ( ! is_array( $rules ) || ! is_array( $cart_items ) ) {
1320 return 0;
1321 }
1322
1323 $free_quantity_total = 0;
1324
1325 foreach ( $cart_items as $item ) {
1326 if ( ! empty( $item['is_free_product'] ) ) {
1327 continue;
1328 }
1329
1330 $effective_product_id = $item['product_id'];
1331
1332 if ( ! empty( $item['variation_id'] ) ) {
1333 $effective_product_id = $item['variation_id'];
1334 }
1335
1336 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1337 continue;
1338 }
1339
1340 $product = wc_get_product( $effective_product_id );
1341
1342 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1343 continue;
1344 }
1345
1346 $line_quantity = (int) $item['quantity'];
1347
1348 foreach ( $rules as $rule ) {
1349 $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1350 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1351 $maximum_quantity = ! empty( $rule['max'] ) ? absint( $rule['max'] ) : 0; // phpcs:ignore
1352 $get_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1353 $recursive = ! empty( $rule['recursive'] ) && 'yes' === $rule['recursive']; // phpcs:ignore
1354
1355 if ( $minimum_quantity <= 0 || $get_quantity <= 0 || $line_quantity < $minimum_quantity ) {
1356 continue;
1357 }
1358
1359 // Respect the tier's upper bound so each product matches its tier.
1360 if ( $maximum_quantity > 0 && $line_quantity > $maximum_quantity && 'yes' !== $rule['recursive'] ) {
1361 continue;
1362 }
1363
1364 $free_quantity_total += $recursive ? ( (int) floor( $line_quantity / $minimum_quantity ) * $get_quantity ) : $get_quantity; // phpcs:ignore
1365 }
1366 }
1367
1368 return $free_quantity_total;
1369 }
1370
1371 /**
1372 * Get Offer Label.
1373 *
1374 * @param array $rule Discount Rule.
1375 */
1376 private function get_item_offers( array $rule ): array {
1377 $label = '';
1378 $quantity = $rule['min'];
1379
1380 if ( ! empty( $rule['max'] ) ) {
1381 $quantity .= ' - ' . $rule['max'];
1382 }
1383
1384 if ( 'free' === $rule['discount_type'] ) {
1385 $label = 'Free';
1386 }
1387
1388 if ( 'percent' === $rule['discount_type'] ) {
1389 $label = $rule['discount_value'] . '% off';
1390 }
1391
1392 if ( 'fixed' === $rule['discount_type'] ) {
1393 $label = wc_price( $rule['discount_value'] ) . ' off';
1394 }
1395
1396 if ( ! empty( $rule['get_quantity'] ) ) {
1397 $quantity = $rule['get_quantity'];
1398 }
1399
1400 return array(
1401 'quantity' => $quantity,
1402 'label' => $label,
1403 );
1404 }
1405
1406 /**
1407 * Verify Bulk Intent Rules.
1408 *
1409 * @param int $quantity Item Quantity.
1410 * @param array $rule Discount Rules.
1411 */
1412 private function verify_bulk_rule( int $quantity, array $rule ): bool {
1413 $min = abs( $rule['min'] );
1414
1415 if ( empty( $rule['max'] ) ) {
1416 $max = PHP_INT_MAX;
1417 } else {
1418 $max = abs( $rule['max'] );
1419 }
1420
1421 if ( $quantity >= $min && $quantity <= $max ) {
1422 return true;
1423 }
1424
1425 return $quantity >= $max;
1426 }
1427
1428 /**
1429 * Verify Bundle Intent Rules.
1430 *
1431 * @param int $quantity Item Quantity.
1432 * @param array $rule Discount Rules.
1433 */
1434 private function verify_bundle_rule( int $quantity, array $rule ): bool {
1435 $rule_basis = abs( $rule['min'] );
1436
1437 if ( $rule['recursive'] === 'yes' ) {
1438 if ( $quantity >= $rule_basis && ( $quantity % $rule_basis === 0 || $quantity > $rule_basis ) ) {
1439 return true;
1440 }
1441 } elseif ( $quantity >= $rule_basis ) {
1442 return true;
1443 }
1444
1445 return false;
1446 }
1447
1448 /**
1449 * Verify BuyXGetX Intent Rules.
1450 *
1451 * @param float $quantity Item Quantity.
1452 * @param array $item Cart Item.
1453 * @param array $rule Discount Rules.
1454 */
1455 private function verify_buyxgetx_rule( float $quantity, array $item, array $rule ): bool {//phpcs:ignore
1456
1457 $verified = ( $quantity >= $rule['min'] && $quantity <= $rule['max'] );
1458
1459 if ( $verified && $rule['recursive'] === 'no' ) {
1460 return true;
1461 }
1462
1463 $base_quantity = abs( $rule['min'] );
1464
1465 if ( $rule['recursive'] === 'yes' && ! isset( $rule['max'] ) ) {
1466 if ( $quantity >= $base_quantity && $quantity % $base_quantity === 0 ) {
1467 return true;
1468 }
1469 } elseif ( $quantity >= $base_quantity ) {
1470 return true;
1471 }
1472
1473 return false;
1474 }
1475
1476 /**
1477 * Verify BuyXGetY Intent Rules.
1478 *
1479 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1480 * @param float $quantity Item Quantity.
1481 * @param array $item Cart Item.
1482 * @param array $rule Discount Rules.
1483 */
1484 private function verify_buyxgety_rule( Config $campaign, float $quantity, array $item, array $rule ): bool {
1485 if ( ! is_array( $rule['get_ids'] ) ) {
1486 return false;
1487 }
1488
1489 $id = $this->get_cart_item_id( $item );
1490 $rule_ids = array_column( $rule['get_ids'], 'id' );
1491
1492 /**
1493 * This code use for automatically apply free items rule for all product BOGO campaigns.
1494 */
1495 if ( $rule['discount_type'] === 'free' && 'all' === $campaign->get_bogo_type() ) {
1496 return $this->verify_buyxgetx_rule( $quantity, $item, $rule );
1497 }
1498
1499 /**
1500 * This code use for automatically apply free items rule product based BOGO campaigns.
1501 */
1502 if ( $rule['discount_type'] === 'free' && 'products' === $campaign->get_bogo_type() ) {
1503 return $this->verify_xproduct_cart_rule( $campaign, $rule );
1504 }
1505
1506 if ( 'products' === $campaign->get_bogo_type() && in_array( $id, $rule_ids, true ) ) {
1507 return $this->verify_xproduct_cart_rule( $campaign, $rule );
1508 }
1509
1510 if ( 'categories' === $campaign->get_bogo_type() && $this->is_in_category( $id, $rule_ids ) ) {
1511 return $this->verify_xproduct_cart_rule( $campaign, $rule );
1512 }
1513
1514 return false;
1515 }
1516
1517 /**
1518 * Verify XProduct Cart Rule.
1519 *
1520 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1521 * @param array $rule Discount Rules.
1522 */
1523 /**
1524 * Pick the single BuyXGetY tier that applies for the current cart.
1525 *
1526 * Tiers are ranked by `min`: the winner is the qualifying rule with the
1527 * highest `min` that the buy quantity still meets (the broken upper `max` is
1528 * ignored — a rule qualifies on `qty >= min`, mirroring the gate). On a tie,
1529 * the later rule wins. Returns a list with 0 or 1 rule.
1530 *
1531 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1532 * @param array $rules All discount rules.
1533 */
1534 private function select_bogo_tier_rule( Config $campaign, array $rules ): array {
1535 $qualifying_rule = null;
1536 $qualifying_minimum = -1;
1537
1538 foreach ( $rules as $rule ) {
1539 $rule_array = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1540
1541 // Rule only counts if the cart meets its buy-X threshold.
1542 if ( ! $this->verify_xproduct_cart_rule( $campaign, $rule_array ) ) {
1543 continue;
1544 }
1545
1546 $minimum_quantity = isset( $rule_array['min'] ) ? absint( $rule_array['min'] ) : 0; // phpcs:ignore
1547
1548 if ( $minimum_quantity >= $qualifying_minimum ) { // phpcs:ignore
1549 $qualifying_minimum = $minimum_quantity;
1550 $qualifying_rule = $rule;
1551 }
1552 }
1553
1554 return null === $qualifying_rule ? array() : array( $qualifying_rule ); // phpcs:ignore
1555 }
1556
1557 private function verify_xproduct_cart_rule( Config $campaign, array $rule ): bool { //phpcs:ignore
1558 /**
1559 * "Count Quantity As" combined / variations: pool the buy-X quantity
1560 * across cart lines instead of requiring a single line to reach `min`,
1561 * so a mixed cart (e.g. 1 + 1 of different products) can satisfy a
1562 * "buy 2" threshold. QuantityCounter honours the same min / max /
1563 * recursive semantics used elsewhere.
1564 */
1565 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) { // phpcs:ignore
1566 $quantity_counter = new QuantityCounter;
1567
1568 return ! empty( $quantity_counter->get_eligible_units( WC()->cart, $campaign, $rule ) );
1569 }
1570
1571 $cart = WC()->cart->get_cart();
1572
1573 foreach ( $cart as $item ) {
1574 // Skip free products - they must not count towards the BOGO buy quantity,
1575 // otherwise granted free items re-qualify as buy-X and escalate the tier.
1576 if ( ! empty( $item['is_free_product'] ) ) {
1577 continue;
1578 }
1579
1580 $quantity = $item['quantity'];
1581 $id = $item['product_id'];
1582
1583 if ( ! empty( $item['variation_id'] ) ) {
1584 $id = $item['variation_id'];
1585 }
1586
1587 if ( $campaign->product_is_applicable( $id ) && $this->verify_buyxgetx_rule( $quantity, $item, $rule ) ) {
1588 $product = wc_get_product( $id );
1589 $info = array( 'product' => $product );
1590
1591 if ( ! Helper::is_filter_passed( $campaign, $info ) ) {
1592 continue;
1593 }
1594
1595 return true;
1596 }
1597 }
1598
1599 return false;
1600 }
1601
1602 /**
1603 * Verify YProduct in Cart.
1604 *
1605 * @param array $rule_ids Rule Product IDs.
1606 * @param array $cart Cart.
1607 * @param string $bogo_type BOGO Type.
1608 * @return bool Returns true if a valid Y product is found in the cart, false otherwise.
1609 */
1610 private function verify_yproduct_in_cart( array $rule_ids, array $cart, string $bogo_type ): bool { //phpcs:ignore
1611 if ( 'categories' === $bogo_type && ! empty( $rule_ids ) ) {
1612 foreach ( $cart as $item ) {
1613 $id = $item['product_id'];
1614
1615 if ( ! empty( $item['variation_id'] ) ) {
1616 $id = $item['variation_id'];
1617 }
1618
1619 if ( ! $this->is_in_category( $id, $rule_ids ) ) {
1620 continue;
1621 }
1622
1623 return true; // Found a valid Y product
1624 }
1625 }
1626
1627 if ( 'products' === $bogo_type && ! empty( $rule_ids ) ) {
1628 foreach ( $cart as $item ) {
1629 $id = $item['product_id'];
1630
1631 if ( ! empty( $item['variation_id'] ) ) {
1632 $id = $item['variation_id'];
1633 }
1634
1635 if ( ! in_array( $id, $rule_ids, true ) ) {
1636 continue;
1637 }
1638
1639 return true; // Found a valid Y product
1640 }
1641 }
1642
1643 return false;
1644 }
1645
1646 /**
1647 * Get Y products from cart.
1648 *
1649 * @param array $rule_ids Rule Product IDs.
1650 * @param array $cart Cart.
1651 * @param string $bogo_type BOGO Type.
1652 */
1653 private function get_y_product( array $rule_ids, array $cart, string $bogo_type ): array { //phpcs:ignore
1654 $y_products = array();
1655
1656 if ( 'categories' === $bogo_type && ! empty( $rule_ids ) ) {
1657 foreach ( $cart as $item ) {
1658 $id = $item['product_id'];
1659
1660 if ( ! empty( $item['variation_id'] ) ) {
1661 $id = $item['variation_id'];
1662 }
1663
1664 if ( ! $this->is_in_category( $id, $rule_ids ) ) {
1665 continue;
1666 }
1667
1668 $y_products[] = $item; // Push the full cart item
1669 }
1670 }
1671
1672 if ( 'products' === $bogo_type && ! empty( $rule_ids ) ) {
1673 foreach ( $cart as $item ) {
1674 $id = $item['product_id'];
1675
1676 if ( ! empty( $item['variation_id'] ) ) {
1677 $id = $item['variation_id'];
1678 }
1679
1680 if ( ! in_array( $id, $rule_ids, true ) ) {
1681 continue;
1682 }
1683
1684 $y_products[] = $item; // Push the full cart item
1685 }
1686 }
1687
1688 return $y_products;
1689 }
1690
1691 }
1692