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

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

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