PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.16
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.16
1.4.16 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 All 179 releases
disco / app / Intents / IntentHelper.php

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

1,723 lines 55.5 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 if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
657 continue;
658 }
659
660 $items = $this->get_items_for_discount( $cart, $intent->campaign );
661 $get_discounts = $intent->get_discounts( $items, $cart );
662
663 if ( empty( $get_discounts ) ) {
664 continue;
665 }
666
667 foreach ( $get_discounts as $item_id => $discount ) {
668 /**
669 * Keep the campaign alongside its amount at the same index.
670 *
671 * Staging cannot happen yet: several campaigns can offer an amount
672 * for the same item and only one of them survives the min/max
673 * reduction below. Crediting every campaign here would consume the
674 * usage limit of campaigns whose discount never reached the cart.
675 */
676 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
677 $discounts[ $item_id ]['campaigns'][] = (int) $intent->campaign->id;
678 }
679 }
680
681 // Check if the discounts are empty.
682 if ( empty( $discounts ) ) {
683 return false;
684 }
685
686 $applied_campaign_ids = array();
687
688 // Get the min or max discount amount for each item.
689 foreach ( $discounts as $item_id => $discount ) {
690 $winning_amount = $this->min_max_average( $discount['discounts'] );
691 $winning_index = array_search( $winning_amount, $discount['discounts'], true );
692
693 if ( false !== $winning_index && isset( $discount['campaigns'][ $winning_index ] ) ) {
694 $applied_campaign_ids[ $discount['campaigns'][ $winning_index ] ] = true;
695 }
696
697 $discounts[ $item_id ] = $winning_amount;
698 }
699
700 // Stage only the campaigns that actually won an item.
701 foreach ( array_keys( $applied_campaign_ids ) as $applied_campaign_id ) {
702 ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
703 }
704
705 return $discounts;
706 }
707
708 /**
709 * Get valid BOGO category item ID.
710 *
711 * @param array $intents Intents.
712 * @param \WC_Cart $cart Cart.
713 * @param array $discounts Discounts.
714 * @return int|string|null
715 */
716 public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {//phpcs:ignore
717 foreach ( $intents as $intent ) {
718 if (
719 $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
720 $intent->campaign->get_bogo_type() !== 'categories'
721 ) {
722 continue;
723 }
724
725 // Collect every eligible category item (in cart order) with its price,
726 // then pick the reward item per the campaign's selection strategy.
727 $candidates = array();
728
729 foreach ( $cart->get_cart() as $item ) {
730 $cart_item_id = $this->get_cart_item_id( $item );
731
732 if ( isset( $discounts[ $cart_item_id ] ) && $discounts[ $cart_item_id ] > 0 ) { // phpcs:ignore
733 $candidates[ $cart_item_id ] = CalcFactory::get_price( $item );
734 }
735 }
736
737 if ( empty( $candidates ) ) {
738 continue;
739 }
740
741 return $this->pick_reward_item( $candidates, $intent->campaign->get_free_item_selection() );
742 }
743
744 return null;
745 }
746
747 /**
748 * Pick the reward cart-item id from eligible candidates.
749 *
750 * Honours the "Free Item Selection" strategy when more than one product
751 * from the reward category is in the cart:
752 * - `cart_order` (default) → the first eligible item in cart order.
753 * - `lowest` → the lowest-priced eligible item.
754 * - `highest` → the highest-priced eligible item.
755 * Ties keep cart order (first match wins).
756 *
757 * @param array<int|string, float> $candidates Map of item id => price, in cart order.
758 * @param string $selection Selection strategy.
759 * @return int|string|null
760 */
761 private function pick_reward_item( array $candidates, string $selection ) {
762 if ( 'lowest' === $selection ) {
763 return array_keys( $candidates, min( $candidates ), true )[0];
764 }
765
766 if ( 'highest' === $selection ) {
767 return array_keys( $candidates, max( $candidates ), true )[0];
768 }
769
770 return array_key_first( $candidates );
771 }
772
773 /**
774 * Prepare discounts for cart items.
775 *
776 * @param array $intents Intents.
777 * @param \WC_Cart $cart Cart.
778 * @return array|false
779 */
780 public function prepare_item_discounts_bogo_free( array $intents, \WC_Cart $cart ) { //phpcs:ignore
781 if ( empty( $intents ) ) {
782 return false;
783 }
784
785 $discounts = array();
786 $candidate_campaign_ids = array();
787
788 // Loop through the intents.
789 foreach ( $intents as $intent ) {
790 /**
791 * Get a discount limit form campaign.
792 *
793 * Compare with total product meta and apply discount
794 */
795 if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
796 continue;
797 }
798
799 $items = $this->get_items_for_discount( $cart, $intent->campaign );
800 $get_discounts = $intent->get_discounts( $items, $cart );
801
802 if ( empty( $get_discounts ) ) {
803 continue;
804 }
805
806 /**
807 * Collect the campaign now, stage it later.
808 *
809 * What this method finally grants is decided well below: the rewards
810 * are unioned across campaigns and a BuyXGetY (products) campaign can
811 * then override the whole result authoritatively. Crediting a campaign
812 * here would consume its usage limit even when the final result grants
813 * it nothing.
814 */
815 $candidate_campaign_ids[] = (int) $intent->campaign->id;
816
817 foreach ( $get_discounts as $item_id => $discount ) {
818 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); // phpcs:ignore
819 $discounts[ $item_id ]['free'] = $discount['free']; // phpcs:ignore
820 $discounts[ $item_id ]['get_ids'] = $discount['get_ids']; // phpcs:ignore
821 $discounts[ $item_id ]['get_qty'] = $discount['get_qty']; // phpcs:ignore
822 $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type(); // phpcs:ignore
823 $discounts[ $item_id ]['free_item_selection'] = $intent->campaign->get_free_item_selection(); // phpcs:ignore
824 }
825 }
826
827 // Check if the discounts are empty.
828 if ( empty( $discounts ) ) {
829 return false;
830 }
831
832 /**
833 * Aggregate the per-item free rewards into the flat shape the cart hook
834 * consumes. Union every qualifying reward id (previously this overwrote
835 * on each pass, so only the last cart item was ever granted its free
836 * product) and keep a per-id quantity map for products whose free counts
837 * differ (e.g. recursive BuyXGetX).
838 */
839 $reward_product_ids = array();
840 $reward_quantity_map = array();
841 $has_free_items = false;
842 $bogo_type = 'products';
843 $free_item_selection = 'cart_order';
844
845 foreach ( $discounts as $item_id => $discount ) {
846 $has_free_items = $discount['free'];
847 $bogo_type = $discount['bogo_type'] ?? 'products';
848 $free_item_selection = $discount['free_item_selection'] ?? 'cart_order';
849
850 $rule_reward_ids = ! empty( $discount['get_ids'] ) ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); // phpcs:ignore
851
852 foreach ( $rule_reward_ids as $reward_product_id ) {
853 $reward_product_id = (int) $reward_product_id; // phpcs:ignore
854 $reward_product_ids[] = $reward_product_id; // phpcs:ignore
855 $reward_quantity_map[ $reward_product_id ] = (int) $discount['get_qty']; // phpcs:ignore
856 }
857 }
858
859 $reward_product_ids = array_values( array_unique( $reward_product_ids ) );
860
861 $discounts['discount'] = 0.0;
862 $discounts['free'] = $has_free_items;
863 $discounts['get_ids'] = $reward_product_ids;
864 $discounts['get_qty'] = empty( $reward_quantity_map ) ? 0 : max( $reward_quantity_map ); // phpcs:ignore
865 $discounts['get_qty_map'] = $reward_quantity_map;
866 $discounts['bogo_type'] = $bogo_type;
867 $discounts['free_item_selection'] = $free_item_selection;
868
869 /**
870 * BuyXGetY (products): replace the flat aggregation with an authoritative
871 * reward map keyed by the tier's own get product, so each cart product
872 * that qualifies grants its tier's reward.
873 *
874 * Category BOGO is not handled here — {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}
875 * owns its reward selection, buy reservation and cart reconciliation.
876 */
877 foreach ( $intents as $intent ) {
878 $bogo_type = $intent->campaign->get_bogo_type();
879
880 if ( 'BuyXGetY' !== $intent->campaign->get_discount_intent() || 'products' !== $bogo_type ) {
881 continue;
882 }
883
884 // Only free-type rules grant free items; a percent/fixed BOGO must not
885 // fabricate free products here.
886 $rules = $intent->campaign->get_discount_rules();
887 $first_rule = ( is_array( $rules ) && isset( $rules[0] ) ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
888
889 if ( ! isset( $first_rule['discount_type'] ) || 'free' !== $first_rule['discount_type'] ) {
890 continue;
891 }
892
893 $reward_map = $this->compute_bogo_products_free_map( $intent->campaign, $cart );
894
895 // Always authoritative: an empty map means no qualifying (non-reward)
896 // purchase, so no free items — do not fall back to the flat count.
897 $discounts['get_ids'] = array_keys( $reward_map ); // phpcs:ignore
898 $discounts['get_qty_map'] = $reward_map; // phpcs:ignore
899 // Each get-product is freed its own quantity.
900 $discounts['get_qty'] = empty( $reward_map ) ? 0 : max( $reward_map ); // phpcs:ignore
901 $discounts['free'] = ! empty( $reward_map ); // phpcs:ignore
902 $discounts['bogo_type'] = $bogo_type;
903 $discounts['free_item_selection'] = $intent->campaign->get_free_item_selection();
904
905 /**
906 * This branch is authoritative for the whole result, so the campaign
907 * that owns it is the only one that can be credited.
908 */
909 $candidate_campaign_ids = array( (int) $intent->campaign->id );
910 }
911
912 /**
913 * Stage the campaigns only once the result is final, and only when it
914 * actually grants free items.
915 */
916 if ( ! empty( $discounts['free'] ) ) {
917 foreach ( array_unique( $candidate_campaign_ids ) as $applied_campaign_id ) {
918 ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
919 }
920 }
921
922 return $discounts;
923 }
924
925 /**
926 * Authoritative free Y map for a BuyXGetY (products) campaign.
927 *
928 * Returns get-product id => free quantity, attributing each tier's reward to
929 * that tier's own get-products:
930 * - `separate` → each cart line credits every tier whose range
931 * its own quantity matches ([min,max]); rewards
932 * sum, and each tier credits its own get-products.
933 * - `combined` → one pool over every applicable line; the highest
934 * tier it reaches credits its get-products once.
935 * - `variations` → one pool per parent product (all variations of
936 * the same variable count together, and a simple
937 * product is its own pool); every pool that
938 * reaches a tier credits that tier's get-products,
939 * so two qualifying parents grant two rewards.
940 * Recursive tiers multiply by floor(qty/min) (line qty for separate, pool qty
941 * for combined / variations).
942 *
943 * @param \Disco\App\Utility\Config $campaign Campaign Config.
944 * @param \WC_Cart $cart Cart Object.
945 * @return array<int, int>
946 */
947 private function compute_bogo_products_free_map( Config $campaign, \WC_Cart $cart ): array {//phpcs:ignore
948 $rules = $campaign->get_discount_rules();
949
950 if ( ! is_array( $rules ) || empty( $rules ) ) {
951 return array();
952 }
953
954 $reward_map = array();
955
956 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
957 foreach ( $this->get_bogo_buy_pool_quantities( $cart, $campaign ) as $pool_quantity ) {
958 $this->credit_bogo_pool_reward( $reward_map, $rules, $pool_quantity );
959 }
960
961 return $reward_map;
962 }
963
964 /**
965 * Separate: build the buy quantity per product, then each product earns
966 * the highest tier its quantity qualifies for (min <= qty). The upper
967 * `max` marks where the next tier begins, not a cut-off, so a quantity
968 * above the top tier still earns it and a gap earns the tier below.
969 * Free lines never count.
970 */
971 $buy_quantity_per_product = array();
972
973 foreach ( $cart->get_cart() as $item ) {
974 if ( ! empty( $item['is_free_product'] ) ) {
975 continue;
976 }
977
978 $effective_product_id = $item['product_id'];
979
980 if ( ! empty( $item['variation_id'] ) ) {
981 $effective_product_id = $item['variation_id'];
982 }
983
984 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
985 continue;
986 }
987
988 $product = wc_get_product( $effective_product_id );
989
990 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
991 continue;
992 }
993
994 $buy_quantity_per_product[ (int) $effective_product_id ] = ( $buy_quantity_per_product[ (int) $effective_product_id ] ?? 0 ) + (int) $item['quantity'];
995 }
996
997 foreach ( $buy_quantity_per_product as $quantity ) {
998 $qualifying_rule = null;
999 $qualifying_minimum = -1;
1000
1001 foreach ( $rules as $rule ) {
1002 $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1003 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1004 $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1005
1006 if ( $minimum_quantity <= 0 || $reward_quantity <= 0 || empty( $rule['get_ids'] ) ) {
1007 continue;
1008 }
1009
1010 if ( $quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1011 $qualifying_rule = $rule;
1012 $qualifying_minimum = $minimum_quantity;
1013 }
1014 }
1015
1016 if ( null === $qualifying_rule ) {
1017 continue;
1018 }
1019
1020 $minimum_quantity = absint( $qualifying_rule['min'] );
1021 $reward_quantity = (int) $qualifying_rule['get_quantity'];
1022 $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1023 $reward_total = ( $is_recursive && $minimum_quantity > 0 ) ? ( (int) floor( $quantity / $minimum_quantity ) * $reward_quantity ) : $reward_quantity; // phpcs:ignore
1024
1025 if ( $reward_total > 0 ) { // phpcs:ignore
1026 foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1027 $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $reward_total;
1028 }
1029 }
1030 }
1031
1032 return $reward_map;
1033 }
1034
1035 /**
1036 * Pooled buy quantities for combined / variations, excluding free lines.
1037 *
1038 * `combined` returns a single pool over every applicable line. `variations`
1039 * returns one pool per parent product, so variations of the same variable
1040 * product count together while different parents stay apart — the same
1041 * grouping {@see QuantityCounter} applies to the buy-X gate.
1042 *
1043 * @param \WC_Cart $cart Cart Object.
1044 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1045 * @return array<string, int> Pool key => pooled quantity.
1046 */
1047 private function get_bogo_buy_pool_quantities( \WC_Cart $cart, Config $campaign ): array {//phpcs:ignore
1048 $pool_quantities = array();
1049 $pool_per_parent = 'variations' === $campaign->get_count_quantity_as();
1050 $cart_items = $cart->get_cart();
1051
1052 if ( ! is_array( $cart_items ) ) {
1053 return $pool_quantities;
1054 }
1055
1056 foreach ( $cart_items as $item ) {
1057 if ( ! empty( $item['is_free_product'] ) ) {
1058 continue;
1059 }
1060
1061 $effective_product_id = $item['product_id'];
1062
1063 if ( ! empty( $item['variation_id'] ) ) {
1064 $effective_product_id = $item['variation_id'];
1065 }
1066
1067 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1068 continue;
1069 }
1070
1071 $product = wc_get_product( $effective_product_id );
1072
1073 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1074 continue;
1075 }
1076
1077 $pool_key = $pool_per_parent ? 'parent_' . (int) $item['product_id'] : 'all'; // phpcs:ignore
1078
1079 $pool_quantities[ $pool_key ] = ( $pool_quantities[ $pool_key ] ?? 0 ) + (int) $item['quantity'];
1080 }
1081
1082 return $pool_quantities;
1083 }
1084
1085 /**
1086 * Credit one pool's reward to the get-products of the tier it qualifies for.
1087 *
1088 * The winning tier is the one with the highest `min` the pool reaches; a
1089 * recursive tier multiplies its reward by the number of complete sets.
1090 *
1091 * @param array $reward_map Reward map (get-product id => quantity), by reference.
1092 * @param array $rules Discount rules.
1093 * @param int $pool_quantity Pooled buy quantity.
1094 */
1095 private function credit_bogo_pool_reward( array &$reward_map, array $rules, int $pool_quantity ): void {//phpcs:ignore
1096 $qualifying_rule = null;
1097 $qualifying_minimum = -1;
1098
1099 foreach ( $rules as $rule ) {
1100 $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1101 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1102
1103 if ( $minimum_quantity > 0 && $pool_quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1104 $qualifying_rule = $rule;
1105 $qualifying_minimum = $minimum_quantity;
1106 }
1107 }
1108
1109 if ( null === $qualifying_rule || empty( $qualifying_rule['get_ids'] ) ) {
1110 return;
1111 }
1112
1113 $minimum_quantity = absint( $qualifying_rule['min'] );
1114 $reward_quantity = ! empty( $qualifying_rule['get_quantity'] ) ? (int) $qualifying_rule['get_quantity'] : 0; // phpcs:ignore
1115 $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1116 $quantity = ( $is_recursive && $minimum_quantity > 0 ) ? (int) floor( $pool_quantity / $minimum_quantity ) * $reward_quantity : $reward_quantity; // phpcs:ignore
1117
1118 if ( $quantity <= 0 ) {
1119 return;
1120 }
1121
1122 foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1123 $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $quantity;
1124 }
1125 }
1126
1127 /**
1128 * Is it a bogo campaign?
1129 *
1130 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1131 */
1132 public function is_bogo( Config $campaign ): bool { // phpcs:ignore
1133 return in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true );
1134 }
1135
1136 /**
1137 * Check cart is valid or not
1138 */
1139 public function cart_is_valid(): bool {
1140 return WC()->cart instanceof \WC_Cart && ! WC()->cart->is_empty();
1141 }
1142
1143 /**
1144 * Get min or max price from an array discounted prices according to plugin settings.
1145 *
1146 * @param array $discounts Discounted Amounts.
1147 */
1148 public function min_max_average( array $discounts ): float {
1149 $calculation_type = Settings::get( 'min_max_discount_amount' );
1150
1151 if ( empty( $discounts ) ) {
1152 return 0.00;
1153 }
1154
1155 if ( 'max' === $calculation_type ) {
1156 return max( $discounts );
1157 }
1158
1159 return min( $discounts );
1160 }
1161
1162 /**
1163 * Strip the embedded tax portion from a discount amount.
1164 *
1165 * When WooCommerce is set to enter prices inclusive of tax, the discount
1166 * amount computed by the intents also carries that tax (e.g. a 50 discount
1167 * becomes 55 at 10% tax). The cart fee is added as non-taxable, so the tax
1168 * must be removed here to keep the net discount correct.
1169 *
1170 * When a cart is supplied, the embedded-tax portion is derived from the
1171 * cart's own subtotal and subtotal tax rather than assuming the whole
1172 * amount carries the standard rate. This keeps mixed carts correct where
1173 * some products are taxable and others are not (or use different tax
1174 * classes): non-taxable products add to the subtotal but contribute zero
1175 * tax, so the effective inclusive-tax ratio drops accordingly.
1176 *
1177 * @since 1.3.54
1178 * @param float $amount Tax-inclusive discount amount.
1179 * @param \WC_Cart|null $cart Optional cart used to derive the effective tax ratio.
1180 * @return float Tax-exclusive discount amount.
1181 */
1182 public function get_discount_exclude_tax( float $amount, $cart = null ): float {
1183 if ( ! function_exists( 'wc_prices_include_tax' ) || ! wc_prices_include_tax() ) {
1184 return $amount;
1185 }
1186
1187 if ( ! class_exists( 'WC_Tax' ) ) {
1188 return $amount;
1189 }
1190
1191 // Prefer a product-derived inclusive-tax ratio so mixed carts strip
1192 // only the tax actually embedded. The discount base is built from each
1193 // product's tax-inclusive price (see CalcFactory::get_price), so we
1194 // rebuild the same base excluding tax per product. wc_get_price_excluding_tax
1195 // respects each product's own tax status and class: a non-taxable
1196 // product returns its price unchanged and contributes zero embedded tax.
1197 if ( $cart instanceof \WC_Cart && function_exists( 'wc_get_price_excluding_tax' ) ) {
1198 return $this->strip_tax_using_cart_ratio( $amount, $cart );
1199 }
1200
1201 // Fallback: rates for the standard tax class at the customer location.
1202 $rates = \WC_Tax::get_rates( '' );
1203
1204 if ( empty( $rates ) ) {
1205 return $amount;
1206 }
1207
1208 // Tax embedded inside the inclusive amount, then remove it.
1209 $tax_total = array_sum( \WC_Tax::calc_inclusive_tax( $amount, $rates ) );
1210
1211 return $amount - $tax_total;
1212 }
1213
1214 /**
1215 * Strip embedded tax from a discount using the cart's own inclusive-tax ratio.
1216 *
1217 * Rebuilds the discount base (sum of tax-inclusive prices) and its
1218 * tax-exclusive counterpart per product, then scales the discount by the
1219 * excl/incl ratio. Non-taxable products contribute equally to both sums,
1220 * so their share keeps no tax stripped.
1221 *
1222 * @param float $amount Tax-inclusive discount amount.
1223 * @param \WC_Cart $cart Cart used to derive the ratio.
1224 * @return float Tax-exclusive discount amount.
1225 */
1226 private function strip_tax_using_cart_ratio( float $amount, \WC_Cart $cart ): float {
1227 $incl_sum = 0.0;
1228 $excl_sum = 0.0;
1229 $cart_items = $cart->get_cart();
1230
1231 if ( ! is_array( $cart_items ) ) {
1232 $cart_items = array();
1233 }
1234
1235 foreach ( $cart_items as $cart_item ) {
1236 if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof \WC_Product ) {
1237 continue;
1238 }
1239
1240 $product = $cart_item['data'];
1241 $qty = (float) ( $cart_item['quantity'] ?? 1 );
1242 $price_incl = (float) $product->get_price();
1243 $price_excl = (float) wc_get_price_excluding_tax( $product, array( 'price' => $price_incl ) );
1244
1245 $incl_sum += $price_incl * $qty;
1246 $excl_sum += $price_excl * $qty;
1247 }
1248
1249 // No priced items in the cart -> nothing to strip.
1250 if ( $incl_sum <= 0 ) {
1251 return $amount;
1252 }
1253
1254 $net = $amount * $excl_sum / $incl_sum;
1255
1256 $decimals = 2;
1257
1258 if ( function_exists( 'wc_get_price_decimals' ) ) {
1259 $decimals = wc_get_price_decimals();
1260 }
1261
1262 return round( $net, $decimals );
1263 }
1264
1265 /**
1266 * Total qualifying quantity for recursive BOGO.
1267 *
1268 * Sums the quantity of every cart item that passes the campaign's
1269 * few-products / "all" filter (product_is_applicable) and its conditions
1270 * filter (is_filter_passed), excluding products Disco added as free. This is
1271 * the combined "buy" quantity a recursive rule scales the free / discount
1272 * quantity against, so products excluded by either filter are not counted.
1273 *
1274 * @param \WC_Cart $cart Cart object.
1275 * @param \Disco\App\Utility\Config $campaign Campaign config.
1276 */
1277 private function get_total_applicable_quantity( $cart, $campaign ): int {
1278 $total = 0;
1279
1280 $cart_items = $cart->get_cart();
1281
1282 if ( ! is_array( $cart_items ) ) {
1283 return $total;
1284 }
1285
1286 foreach ( $cart_items as $item ) {
1287 if ( ! empty( $item['is_free_product'] ) ) {
1288 continue;
1289 }
1290
1291 $id = $item['product_id'];
1292
1293 if ( ! empty( $item['variation_id'] ) ) {
1294 $id = $item['variation_id'];
1295 }
1296
1297 if ( ! $campaign->product_is_applicable( $id ) ) {
1298 continue;
1299 }
1300
1301 $product = wc_get_product( $id );
1302
1303 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1304 continue;
1305 }
1306
1307 $total += (int) $item['quantity'];
1308 }
1309
1310 return $total;
1311 }
1312
1313 /**
1314 * Whether a campaign is a free products BOGO evaluated per product.
1315 *
1316 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1317 * @param array $rules Discount rules.
1318 */
1319 private function is_per_product_free_bogo( Config $campaign, array $rules ): bool {
1320 if ( 'products' !== $campaign->get_bogo_type() ) {
1321 return false;
1322 }
1323
1324 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
1325 return false;
1326 }
1327
1328 $first_rule = isset( $rules[0] ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
1329
1330 return isset( $first_rule['discount_type'] ) && 'free' === $first_rule['discount_type'];
1331 }
1332
1333 /**
1334 * Total free Y quantity for a products BuyXGetY campaign in `separate` mode.
1335 *
1336 * X is every applicable product. Each cart line grants the reward of every
1337 * rule (tier) whose range its own quantity falls in — `qty` within
1338 * `[min, max]` (no max = min-only) — summed across matching tiers, and ×
1339 * floor(qty/min) when the tier is recursive. The Y product counts as an X
1340 * line too. Summing across all qualifying lines gives the free Y count, so
1341 * two products each matching a different tier grant each tier's reward.
1342 *
1343 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1344 * @param \WC_Cart $cart Cart Object.
1345 */
1346 private function get_products_bogo_free_quantity( Config $campaign, \WC_Cart $cart ): int {//phpcs:ignore
1347 $rules = $campaign->get_discount_rules();
1348 $cart_items = $cart->get_cart();
1349
1350 if ( ! is_array( $rules ) || ! is_array( $cart_items ) ) {
1351 return 0;
1352 }
1353
1354 $free_quantity_total = 0;
1355
1356 foreach ( $cart_items as $item ) {
1357 if ( ! empty( $item['is_free_product'] ) ) {
1358 continue;
1359 }
1360
1361 $effective_product_id = $item['product_id'];
1362
1363 if ( ! empty( $item['variation_id'] ) ) {
1364 $effective_product_id = $item['variation_id'];
1365 }
1366
1367 if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1368 continue;
1369 }
1370
1371 $product = wc_get_product( $effective_product_id );
1372
1373 if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1374 continue;
1375 }
1376
1377 $line_quantity = (int) $item['quantity'];
1378
1379 foreach ( $rules as $rule ) {
1380 $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1381 $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1382 $maximum_quantity = ! empty( $rule['max'] ) ? absint( $rule['max'] ) : 0; // phpcs:ignore
1383 $get_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1384 $recursive = ! empty( $rule['recursive'] ) && 'yes' === $rule['recursive']; // phpcs:ignore
1385
1386 if ( $minimum_quantity <= 0 || $get_quantity <= 0 || $line_quantity < $minimum_quantity ) {
1387 continue;
1388 }
1389
1390 // Respect the tier's upper bound so each product matches its tier.
1391 if ( $maximum_quantity > 0 && $line_quantity > $maximum_quantity && 'yes' !== $rule['recursive'] ) {
1392 continue;
1393 }
1394
1395 $free_quantity_total += $recursive ? ( (int) floor( $line_quantity / $minimum_quantity ) * $get_quantity ) : $get_quantity; // phpcs:ignore
1396 }
1397 }
1398
1399 return $free_quantity_total;
1400 }
1401
1402 /**
1403 * Get Offer Label.
1404 *
1405 * @param array $rule Discount Rule.
1406 */
1407 private function get_item_offers( array $rule ): array {
1408 $label = '';
1409 $quantity = $rule['min'];
1410
1411 if ( ! empty( $rule['max'] ) ) {
1412 $quantity .= ' - ' . $rule['max'];
1413 }
1414
1415 if ( 'free' === $rule['discount_type'] ) {
1416 $label = 'Free';
1417 }
1418
1419 if ( 'percent' === $rule['discount_type'] ) {
1420 $label = $rule['discount_value'] . '% off';
1421 }
1422
1423 if ( 'fixed' === $rule['discount_type'] ) {
1424 $label = wc_price( $rule['discount_value'] ) . ' off';
1425 }
1426
1427 if ( ! empty( $rule['get_quantity'] ) ) {
1428 $quantity = $rule['get_quantity'];
1429 }
1430
1431 return array(
1432 'quantity' => $quantity,
1433 'label' => $label,
1434 );
1435 }
1436
1437 /**
1438 * Verify Bulk Intent Rules.
1439 *
1440 * @param int $quantity Item Quantity.
1441 * @param array $rule Discount Rules.
1442 */
1443 private function verify_bulk_rule( int $quantity, array $rule ): bool {
1444 $min = abs( $rule['min'] );
1445
1446 if ( empty( $rule['max'] ) ) {
1447 $max = PHP_INT_MAX;
1448 } else {
1449 $max = abs( $rule['max'] );
1450 }
1451
1452 if ( $quantity >= $min && $quantity <= $max ) {
1453 return true;
1454 }
1455
1456 return $quantity >= $max;
1457 }
1458
1459 /**
1460 * Verify Bundle Intent Rules.
1461 *
1462 * @param int $quantity Item Quantity.
1463 * @param array $rule Discount Rules.
1464 */
1465 private function verify_bundle_rule( int $quantity, array $rule ): bool {
1466 $rule_basis = abs( $rule['min'] );
1467
1468 if ( $rule['recursive'] === 'yes' ) {
1469 if ( $quantity >= $rule_basis && ( $quantity % $rule_basis === 0 || $quantity > $rule_basis ) ) {
1470 return true;
1471 }
1472 } elseif ( $quantity >= $rule_basis ) {
1473 return true;
1474 }
1475
1476 return false;
1477 }
1478
1479 /**
1480 * Verify BuyXGetX Intent Rules.
1481 *
1482 * @param float $quantity Item Quantity.
1483 * @param array $item Cart Item.
1484 * @param array $rule Discount Rules.
1485 */
1486 private function verify_buyxgetx_rule( float $quantity, array $item, array $rule ): bool {//phpcs:ignore
1487
1488 $verified = ( $quantity >= $rule['min'] && $quantity <= $rule['max'] );
1489
1490 if ( $verified && $rule['recursive'] === 'no' ) {
1491 return true;
1492 }
1493
1494 $base_quantity = abs( $rule['min'] );
1495
1496 if ( $rule['recursive'] === 'yes' && ! isset( $rule['max'] ) ) {
1497 if ( $quantity >= $base_quantity && $quantity % $base_quantity === 0 ) {
1498 return true;
1499 }
1500 } elseif ( $quantity >= $base_quantity ) {
1501 return true;
1502 }
1503
1504 return false;
1505 }
1506
1507 /**
1508 * Verify BuyXGetY Intent Rules.
1509 *
1510 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1511 * @param float $quantity Item Quantity.
1512 * @param array $item Cart Item.
1513 * @param array $rule Discount Rules.
1514 */
1515 private function verify_buyxgety_rule( Config $campaign, float $quantity, array $item, array $rule ): bool {
1516 if ( ! is_array( $rule['get_ids'] ) ) {
1517 return false;
1518 }
1519
1520 $id = $this->get_cart_item_id( $item );
1521 $rule_ids = array_column( $rule['get_ids'], 'id' );
1522
1523 /**
1524 * This code use for automatically apply free items rule for all product BOGO campaigns.
1525 */
1526 if ( $rule['discount_type'] === 'free' && 'all' === $campaign->get_bogo_type() ) {
1527 return $this->verify_buyxgetx_rule( $quantity, $item, $rule );
1528 }
1529
1530 /**
1531 * This code use for automatically apply free items rule product based BOGO campaigns.
1532 */
1533 if ( $rule['discount_type'] === 'free' && 'products' === $campaign->get_bogo_type() ) {
1534 return $this->verify_xproduct_cart_rule( $campaign, $rule );
1535 }
1536
1537 if ( 'products' === $campaign->get_bogo_type() && in_array( $id, $rule_ids, true ) ) {
1538 return $this->verify_xproduct_cart_rule( $campaign, $rule );
1539 }
1540
1541 if ( 'categories' === $campaign->get_bogo_type() && $this->is_in_category( $id, $rule_ids ) ) {
1542 return $this->verify_xproduct_cart_rule( $campaign, $rule );
1543 }
1544
1545 return false;
1546 }
1547
1548 /**
1549 * Verify XProduct Cart Rule.
1550 *
1551 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1552 * @param array $rule Discount Rules.
1553 */
1554 /**
1555 * Pick the single BuyXGetY tier that applies for the current cart.
1556 *
1557 * Tiers are ranked by `min`: the winner is the qualifying rule with the
1558 * highest `min` that the buy quantity still meets (the broken upper `max` is
1559 * ignored — a rule qualifies on `qty >= min`, mirroring the gate). On a tie,
1560 * the later rule wins. Returns a list with 0 or 1 rule.
1561 *
1562 * @param \Disco\App\Utility\Config $campaign Campaign Config.
1563 * @param array $rules All discount rules.
1564 */
1565 private function select_bogo_tier_rule( Config $campaign, array $rules ): array {
1566 $qualifying_rule = null;
1567 $qualifying_minimum = -1;
1568
1569 foreach ( $rules as $rule ) {
1570 $rule_array = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1571
1572 // Rule only counts if the cart meets its buy-X threshold.
1573 if ( ! $this->verify_xproduct_cart_rule( $campaign, $rule_array ) ) {
1574 continue;
1575 }
1576
1577 $minimum_quantity = isset( $rule_array['min'] ) ? absint( $rule_array['min'] ) : 0; // phpcs:ignore
1578
1579 if ( $minimum_quantity >= $qualifying_minimum ) { // phpcs:ignore
1580 $qualifying_minimum = $minimum_quantity;
1581 $qualifying_rule = $rule;
1582 }
1583 }
1584
1585 return null === $qualifying_rule ? array() : array( $qualifying_rule ); // phpcs:ignore
1586 }
1587
1588 private function verify_xproduct_cart_rule( Config $campaign, array $rule ): bool { //phpcs:ignore
1589 /**
1590 * "Count Quantity As" combined / variations: pool the buy-X quantity
1591 * across cart lines instead of requiring a single line to reach `min`,
1592 * so a mixed cart (e.g. 1 + 1 of different products) can satisfy a
1593 * "buy 2" threshold. QuantityCounter honours the same min / max /
1594 * recursive semantics used elsewhere.
1595 */
1596 if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) { // phpcs:ignore
1597 $quantity_counter = new QuantityCounter;
1598
1599 return ! empty( $quantity_counter->get_eligible_units( WC()->cart, $campaign, $rule ) );
1600 }
1601
1602 $cart = WC()->cart->get_cart();
1603
1604 foreach ( $cart as $item ) {
1605 // Skip free products - they must not count towards the BOGO buy quantity,
1606 // otherwise granted free items re-qualify as buy-X and escalate the tier.
1607 if ( ! empty( $item['is_free_product'] ) ) {
1608 continue;
1609 }
1610
1611 $quantity = $item['quantity'];
1612 $id = $item['product_id'];
1613
1614 if ( ! empty( $item['variation_id'] ) ) {
1615 $id = $item['variation_id'];
1616 }
1617
1618 if ( $campaign->product_is_applicable( $id ) && $this->verify_buyxgetx_rule( $quantity, $item, $rule ) ) {
1619 $product = wc_get_product( $id );
1620 $info = array( 'product' => $product );
1621
1622 if ( ! Helper::is_filter_passed( $campaign, $info ) ) {
1623 continue;
1624 }
1625
1626 return true;
1627 }
1628 }
1629
1630 return false;
1631 }
1632
1633 /**
1634 * Verify YProduct in Cart.
1635 *
1636 * @param array $rule_ids Rule Product IDs.
1637 * @param array $cart Cart.
1638 * @param string $bogo_type BOGO Type.
1639 * @return bool Returns true if a valid Y product is found in the cart, false otherwise.
1640 */
1641 private function verify_yproduct_in_cart( array $rule_ids, array $cart, string $bogo_type ): bool { //phpcs:ignore
1642 if ( 'categories' === $bogo_type && ! empty( $rule_ids ) ) {
1643 foreach ( $cart as $item ) {
1644 $id = $item['product_id'];
1645
1646 if ( ! empty( $item['variation_id'] ) ) {
1647 $id = $item['variation_id'];
1648 }
1649
1650 if ( ! $this->is_in_category( $id, $rule_ids ) ) {
1651 continue;
1652 }
1653
1654 return true; // Found a valid Y product
1655 }
1656 }
1657
1658 if ( 'products' === $bogo_type && ! empty( $rule_ids ) ) {
1659 foreach ( $cart as $item ) {
1660 $id = $item['product_id'];
1661
1662 if ( ! empty( $item['variation_id'] ) ) {
1663 $id = $item['variation_id'];
1664 }
1665
1666 if ( ! in_array( $id, $rule_ids, true ) ) {
1667 continue;
1668 }
1669
1670 return true; // Found a valid Y product
1671 }
1672 }
1673
1674 return false;
1675 }
1676
1677 /**
1678 * Get Y products from cart.
1679 *
1680 * @param array $rule_ids Rule Product IDs.
1681 * @param array $cart Cart.
1682 * @param string $bogo_type BOGO Type.
1683 */
1684 private function get_y_product( array $rule_ids, array $cart, string $bogo_type ): array { //phpcs:ignore
1685 $y_products = array();
1686
1687 if ( 'categories' === $bogo_type && ! empty( $rule_ids ) ) {
1688 foreach ( $cart as $item ) {
1689 $id = $item['product_id'];
1690
1691 if ( ! empty( $item['variation_id'] ) ) {
1692 $id = $item['variation_id'];
1693 }
1694
1695 if ( ! $this->is_in_category( $id, $rule_ids ) ) {
1696 continue;
1697 }
1698
1699 $y_products[] = $item; // Push the full cart item
1700 }
1701 }
1702
1703 if ( 'products' === $bogo_type && ! empty( $rule_ids ) ) {
1704 foreach ( $cart as $item ) {
1705 $id = $item['product_id'];
1706
1707 if ( ! empty( $item['variation_id'] ) ) {
1708 $id = $item['variation_id'];
1709 }
1710
1711 if ( ! in_array( $id, $rule_ids, true ) ) {
1712 continue;
1713 }
1714
1715 $y_products[] = $item; // Push the full cart item
1716 }
1717 }
1718
1719 return $y_products;
1720 }
1721
1722 }
1723