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

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

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