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
← All changes | app/Intents/IntentHelper.php +947 -102 1.3.481.4.17 View file →
@@ -14,8 +14,9 @@
14 14 use Disco\App\Campaign;
15 15 use Disco\App\Features\UserLimit;
16 16 use Disco\App\Utility\Config;
17 17 use Disco\App\Utility\Helper;
18 +use Disco\App\Utility\QuantityCounter;
18 19 use Disco\App\Utility\Settings;
19 20
20 21 /**
21 22 * This Class contains all the common function for cart related intents,
@@ -149,9 +150,9 @@
149 150 *
150 151 * @param \Disco\App\Utility\Config $campaign Campaign Config.
151 152 * @param array $item Cart Item.
152 153 * @param array $rule Cart Item.
153 - * @return bool
154 + * @return bool
154 155 */
155 156 public function verify_rules( $campaign, $item, $rule ) {
156 157 if ( is_object( $rule ) ) {
157 158 $rule = (array) $rule;
@@ -188,9 +189,9 @@
188 189 /**
189 190 * Get cart item id.
190 191 *
191 192 * @param array $item Cart Item.
192 - * @return int
193 + * @return int
193 194 */
194 195 public function get_cart_item_id( $item ) {
195 196 $id = $item['product_id'];
196 197
@@ -207,9 +208,9 @@
207 208 * If the cart is not valid, then return 0.
208 209 *
209 210 * @param \Disco\App\Utility\Config $campaign Discount Rules.
210 211 * @param \WC_Cart $cart Cart .
211 - * @return float|int
212 + * @return float|int
212 213 */
213 214 public function get_basis_for_cart( $campaign, $cart ) {
214 215 if ( ! $cart instanceof \WC_Cart || $cart->is_empty() ) {
215 216 return 0;
@@ -227,9 +228,9 @@
227 228 * Returns the quantity or price of the item.
228 229 * If the item is not valid, then return 0.
229 230 *
230 231 * @param array $item Cart Item.
231 - * @return int
232 + * @return int
232 233 */
233 234 public function get_basis_for_item( $item ) {
234 235 if ( empty( $item['quantity'] ) ) {
235 236 return 0;
@@ -244,9 +245,9 @@
244 245 *
245 246 * @param float $cost Item Price or Cart Subtotal.
246 247 * @param string $discount_type Discount Type.
247 248 * @param float $discount_value Discount Value.
248 - * @return float
249 + * @return float
249 250 */
250 251 public function calculate_discount( $cost, $discount_type, $discount_value ) {
251 252 $cost = (float) $cost;
252 253 $discount_value = (float) $discount_value;
@@ -283,9 +284,9 @@
283 284 * Get the discounted amount.
284 285 *
285 286 * @param float $cost Item Price or Cart Subtotal.
286 287 * @param float $discounted_amount Discounted Amount.
287 - */
288 + */
288 289 public function get_amount_after_discount( float $cost, float $discounted_amount ): float {
289 290 $discounted_amount = $cost - $discounted_amount;
290 291
291 292 if ( $discounted_amount < 0 ) {
@@ -299,9 +300,9 @@
299 300 * Check if a number is a multiple of another number for recursive discount.
300 301 *
301 302 * @param float|int $number Number to check.
302 303 * @param float|int $of Number to check against.
303 - * @return bool Returns true if the number is a multiple of the given number, false otherwise.
304 + * @return bool Returns true if the number is a multiple of the given number, false otherwise.
304 305 */
305 306 public function is_multiple( $number, $of ) {
306 307 if ( $of >= $number ) {
307 308 return (float) $of % (float) $number === 0;
@@ -314,9 +315,9 @@
314 315 * Check if a product is in a category.
315 316 *
316 317 * @param int $product_id Product ID.
317 318 * @param array $categories Categories.
318 - * @return bool
319 + * @return bool
319 320 */
320 321 public function is_in_category( $product_id, $categories ) {
321 322 // Ensure $categories is an array
322 323 if ( ! is_array( $categories ) ) {
@@ -347,12 +348,12 @@
347 348 // Include all parent category IDs
348 349 $parent_ids = get_ancestors( $term->term_id, 'product_cat' );
349 350
350 351 if ( empty( $parent_ids ) ) {
351 - continue;
352 - }
352 + continue;
353 + }
353 354
354 - $term_ids = array_merge( $term_ids, $parent_ids );
355 + $term_ids = array_merge( $term_ids, $parent_ids );
355 356 }
356 357
357 358 // Remove duplicates just in case
358 359 $term_ids = array_unique( $term_ids );
@@ -379,18 +380,58 @@
379 380 if ( empty( $items ) ) {
380 381 return $discounts;
381 382 }
382 383
383 - // Loop through the cart items.
384 - foreach ( $items as $item ) {
385 - // Get the item object.
386 - $item_objet = $item['data'];
384 + if ( ! is_array( $rules ) || empty( $rules ) ) {
385 + return $discounts;
386 + }
387 387
388 - // Check if the campaign has rules.
389 - if ( ! is_array( $rules ) || empty( $rules ) ) {
390 - continue;
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;
391 404 }
405 + }
392 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 ) {
393 434 // Loop through the discount rules.
394 435 foreach ( $rules as $rule ) {
395 436 if ( is_object( $rule ) ) {
396 437 $rule = (array) $rule;
@@ -400,60 +441,233 @@
400 441 if ( ! $this->verify_rules( $campaign, $item, $rule ) ) {
401 442 continue;
402 443 }
403 444
404 - $r = md5( microtime() . wp_rand() );
405 - $id = $this->get_cart_item_id( $item );
445 + $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
446 + }//end foreach
447 + }//end foreach
406 448
407 - // Set the discount array.
408 - $discounts[ $id ]['original_price'] = $item_objet->get_price();
409 - $discounts[ $id ]['cart_item_key'] = $item['key'];
449 + return $discounts;
450 + }
410 451
411 - // Set discount applies to
412 - $discounts[ $id ]['discount_applies_to'][ $r ] = CalcFactory::discount_applies_to( $rule, $campaign );
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;
413 476
414 - // Set Discounts.
415 - if ( $rule['discount_type'] === 'free' ) {
416 - $discounted_amount = array(
417 - 'price' => $item_objet->get_price(),
418 - 'discount' => 0,
419 - 'line_subtotal' => $item_objet->get_price() * $item['quantity'],
420 - 'discounted_quantities' => 0,
421 - );
422 - $discounts[ $id ]['free'] = true;
423 - $discounts[ $id ]['get_ids'] = $rule['get_ids'];
424 - $discounts[ $id ]['get_qty'] = CalcFactory::get_free_quantity( $rule, $item );
425 - } else {
426 - $discounted_amount = CalcFactory::get_discount( $rule, $item, $cart, $campaign );
427 - $discounts[ $id ]['free'] = false;
428 - $discounts[ $id ]['get_ids'] = array();
429 - $discounts[ $id ]['get_qty'] = 0;
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 );
430 521 }
431 522
432 - // $discounts[ $id ]['get_ids'] = array_values( $rule['get_ids'] );
523 + continue;
524 + }
433 525
434 - $discounts[ $id ]['discount_types'][ $r ] = $rule['discount_type'];
435 - $discounts[ $id ]['intent'][ $r ] = $campaign->get_discount_intent();
436 - $discounts[ $id ]['prices'][ $r ] = $discounted_amount['price'];
437 - $discounts[ $id ]['discounts'][ $r ] = $discounted_amount['discount'];
438 - $discounts[ $id ]['subtotals'][ $r ] = $discounted_amount['line_subtotal'];
439 - $discounts[ $id ]['discounted_quantities'][ $r ] = $discounted_amount['discounted_quantities'];
440 - $discounts[ $id ]['quantities'][ $r ] = $item['quantity'];
441 - $offers = $this->get_item_offers( $rule );
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 + }
442 531
443 - $discounts[ $id ]['offers'][ $r ] = $offers;
444 - }//end foreach
445 - }//end foreach
532 + $line_units = (int) min( $eligible_line['eligible_qty'], $discountable_units );
446 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 +
447 547 return $discounts;
448 548 }
449 549
450 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 + /**
451 665 * Prepare discounts for cart items.
452 666 *
453 667 * @param array $intents Intents.
454 668 * @param \WC_Cart $cart Cart.
455 - * @return array|false
669 + * @return array|false
456 670 */
457 671 public function prepare_item_discounts( array $intents, \WC_Cart $cart ) { //phpcs:ignore
458 672 if ( empty( $intents ) ) {
459 673 return false;
@@ -458,10 +672,9 @@
458 672 if ( empty( $intents ) ) {
459 673 return false;
460 674 }
461 675
462 - $discounts = array();
463 - $applied_campaign_id = 0;
676 + $discounts = array();
464 677
465 678 // Loop through the intents.
466 679 foreach ( $intents as $intent ) {
467 680 /**
@@ -468,13 +681,9 @@
468 681 * Get a discount limit form campaign.
469 682 *
470 683 * Compare with total product meta and apply discount
471 684 */
472 - $discount_limit = $intent->campaign->discount_max_user;
473 - $applied_campaign_id = $intent->campaign->id;
474 - $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
475 -
476 - if ( ! empty( $discount_limit ) && ( $discount_limit >= 0 && $total_applied_campaign >= $discount_limit ) ) {
685 + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
477 686 continue;
478 687 }
479 688
480 689 $items = $this->get_items_for_discount( $cart, $intent->campaign );
@@ -484,9 +693,18 @@
484 693 continue;
485 694 }
486 695
487 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 + */
488 705 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
706 + $discounts[ $item_id ]['campaigns'][] = (int) $intent->campaign->id;
489 707 }
490 708 }
491 709
492 710 // Check if the discounts are empty.
@@ -493,15 +711,26 @@
493 711 if ( empty( $discounts ) ) {
494 712 return false;
495 713 }
496 714
715 + $applied_campaign_ids = array();
716 +
497 717 // Get the min or max discount amount for each item.
498 718 foreach ( $discounts as $item_id => $discount ) {
499 - $discounts[ $item_id ] = $this->min_max_average( $discount['discounts'] );
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;
500 727 }
501 728
502 - // Set applied campaign on DiscountLimit class.
503 - ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
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 + }
504 733
505 734 return $discounts;
506 735 }
507 736
@@ -510,26 +739,36 @@
510 739 *
511 740 * @param array $intents Intents.
512 741 * @param \WC_Cart $cart Cart.
513 742 * @param array $discounts Discounts.
514 - * @return int|null
743 + * @return int|string|null
515 744 */
516 - public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {
745 + public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {//phpcs:ignore
517 746 foreach ( $intents as $intent ) {
518 747 if (
519 - $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
520 - $intent->campaign->get_bogo_type() !== 'categories'
521 - ) {
522 - continue;
523 - }
748 + $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
749 + $intent->campaign->get_bogo_type() !== 'categories'
750 + ) {
751 + continue;
752 + }
524 753
525 - foreach ( $cart->get_cart() as $item ) {
526 - $item_id = $this->get_cart_item_id( $item );
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();
527 757
528 - if ( isset( $discounts[ $item_id ] ) && $discounts[ $item_id ] > 0 ) {
529 - return $item_id;
530 - }
531 - }
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() );
532 771 }
533 772
534 773 return null;
535 774 }
@@ -534,8 +773,34 @@
534 773 return null;
535 774 }
536 775
537 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 + /**
538 803 * Prepare discounts for cart items.
539 804 *
540 805 * @param array $intents Intents.
541 806 * @param \WC_Cart $cart Cart.
@@ -545,12 +810,11 @@
545 810 if ( empty( $intents ) ) {
546 811 return false;
547 812 }
548 813
549 - $discounts = array();
814 + $discounts = array();
815 + $candidate_campaign_ids = array();
550 816
551 - $applied_campaign_id = 0;
552 -
553 817 // Loop through the intents.
554 818 foreach ( $intents as $intent ) {
555 819 /**
556 820 * Get a discount limit form campaign.
@@ -556,13 +820,9 @@
556 820 * Get a discount limit form campaign.
557 821 *
558 822 * Compare with total product meta and apply discount
559 823 */
560 - $discount_limit = $intent->campaign->discount_max_user;
561 - $applied_campaign_id = $intent->campaign->id;
562 - $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
563 -
564 - if ( ! empty( $discount_limit ) && ( $discount_limit >= 0 && $total_applied_campaign >= $discount_limit ) ) {
824 + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
565 825 continue;
566 826 }
567 827
568 828 $items = $this->get_items_for_discount( $cart, $intent->campaign );
@@ -571,14 +831,26 @@
571 831 if ( empty( $get_discounts ) ) {
572 832 continue;
573 833 }
574 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 +
575 846 foreach ( $get_discounts as $item_id => $discount ) {
576 - $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
577 - $discounts[ $item_id ]['free'] = $discount['free'];
578 - $discounts[ $item_id ]['get_ids'] = $discount['get_ids'];
579 - $discounts[ $item_id ]['get_qty'] = $discount['get_qty'];
580 - $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type();
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
581 853 }
582 854 }
583 855
584 856 // Check if the discounts are empty.
@@ -585,29 +857,309 @@
585 857 if ( empty( $discounts ) ) {
586 858 return false;
587 859 }
588 860
589 - // Get the min or max discount amount for each item.
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 +
590 874 foreach ( $discounts as $item_id => $discount ) {
591 - $discounts['discount'] = $this->min_max_average( $discount['discounts'] );
592 - $discounts['free'] = $discount['free'];
593 - $discounts['get_ids'] = $discount['get_ids'] ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); //@phpcs:ignore
594 - $discounts['get_qty'] = $discount['get_qty'];
595 - $discounts['bogo_type'] = $discount['bogo_type'] ?? 'products';
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 + }
596 886 }
597 887
598 - // Set applied campaign on DiscountLimit class.
599 - ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
888 + $reward_product_ids = array_values( array_unique( $reward_product_ids ) );
600 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 +
601 951 return $discounts;
602 952 }
603 953
604 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 + /**
605 1157 * Is it a bogo campaign?
606 1158 *
607 1159 * @param \Disco\App\Utility\Config $campaign Campaign Config.
608 - */
609 - public function is_bogo( Config $campaign ): bool {
1160 + */
1161 + public function is_bogo( Config $campaign ): bool { // phpcs:ignore
610 1162 return in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true );
611 1163 }
612 1164
613 1165 /**
@@ -620,9 +1172,9 @@
620 1172 /**
621 1173 * Get min or max price from an array discounted prices according to plugin settings.
622 1174 *
623 1175 * @param array $discounts Discounted Amounts.
624 - */
1176 + */
625 1177 public function min_max_average( array $discounts ): float {
626 1178 $calculation_type = Settings::get( 'min_max_discount_amount' );
627 1179
628 1180 if ( empty( $discounts ) ) {
@@ -636,8 +1188,248 @@
636 1188 return min( $discounts );
637 1189 }
638 1190
639 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 + /**
640 1432 * Get Offer Label.
641 1433 *
642 1434 * @param array $rule Discount Rule.
643 1435 */
@@ -787,12 +1579,65 @@
787 1579 *
788 1580 * @param \Disco\App\Utility\Config $campaign Campaign Config.
789 1581 * @param array $rule Discount Rules.
790 1582 */
791 - private function verify_xproduct_cart_rule( Config $campaign, array $rule ): bool {
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 +
792 1631 $cart = WC()->cart->get_cart();
793 1632
794 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 +
795 1640 $quantity = $item['quantity'];
796 1641 $id = $item['product_id'];
797 1642
798 1643 if ( ! empty( $item['variation_id'] ) ) {