PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.16
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.16
1.4.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 1.3.50 1.3.49 All 181 releases
← All changes | app/Intents/IntentHelper.php +834 -87 1.3.491.4.16 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,
@@ -379,24 +380,58 @@
379 380 if ( empty( $items ) ) {
380 381 return $discounts;
381 382 }
382 383
384 + if ( ! is_array( $rules ) || empty( $rules ) ) {
385 + return $discounts;
386 + }
387 +
383 388 /**
389 + * BuyXGetY tiers are mutually exclusive: only one rule applies per
390 + * campaign, selected by the buy quantity. Without this, tiers rewarding
391 + * different get-products (e.g. rule 1 → product A, rule 2 → product B)
392 + * would all fire and stack. Every other intent already collapses
393 + * overlapping rules per product via min_max_average.
394 + *
395 + * Exception: a free products BOGO in `separate` mode is evaluated
396 + * per-product (each cart product uses the tier(s) its own quantity
397 + * matches), so all rules are kept and the reward is summed downstream.
398 + */
399 + if ( 'BuyXGetY' === $campaign->get_discount_intent() && ! $this->is_per_product_free_bogo( $campaign, $rules ) ) {
400 + $rules = $this->select_bogo_tier_rule( $campaign, $rules );
401 +
402 + if ( empty( $rules ) ) {
403 + return $discounts;
404 + }
405 + }
406 +
407 + /**
384 408 * Combined qualifying quantity for recursive BOGO, filtered by the
385 409 * conditions and few-products filters and excluding Disco free items.
386 410 */
387 411 $total_applicable_qty = $this->get_total_applicable_quantity( $cart, $campaign );
388 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 +
389 432 // Loop through the cart items.
390 433 foreach ( $items as $item ) {
391 - // Get the item object.
392 - $item_objet = $item['data'];
393 -
394 - // Check if the campaign has rules.
395 - if ( ! is_array( $rules ) || empty( $rules ) ) {
396 - continue;
397 - }
398 -
399 434 // Loop through the discount rules.
400 435 foreach ( $rules as $rule ) {
401 436 if ( is_object( $rule ) ) {
402 437 $rule = (array) $rule;
@@ -406,61 +441,196 @@
406 441 if ( ! $this->verify_rules( $campaign, $item, $rule ) ) {
407 442 continue;
408 443 }
409 444
410 - $r = md5( microtime() . wp_rand() );
411 - $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
412 448
413 - // Set the discount array.
414 - $discounts[ $id ]['original_price'] = $item_objet->get_price();
415 - $discounts[ $id ]['cart_item_key'] = $item['key'];
449 + return $discounts;
450 + }
416 451
417 - // Set discount applies to
418 - $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;
419 476
420 - // Set Discounts.
421 - if ( $rule['discount_type'] === 'free' ) {
422 - $discounted_amount = array(
423 - 'price' => $item_objet->get_price(),
424 - 'discount' => 0,
425 - 'line_subtotal' => $item_objet->get_price() * $item['quantity'],
426 - 'discounted_quantities' => 0,
427 - );
428 - $discounts[ $id ]['free'] = true;
429 - $discounts[ $id ]['get_ids'] = $rule['get_ids'];
430 - $discounts[ $id ]['get_qty'] = CalcFactory::get_free_quantity( $rule, $item );
477 + foreach ( $rules as $rule ) {
478 + if ( is_object( $rule ) ) {
479 + $rule = (array) $rule;
480 + }
431 481
482 + $eligible_lines = $quantity_counter->get_eligible_units( $cart, $campaign, $rule );
483 +
484 + if ( empty( $eligible_lines ) ) {
485 + continue;
486 + }
487 +
488 + // Total qualifying units across the pooled lines.
489 + $total_qualifying_units = 0;
490 +
491 + foreach ( $eligible_lines as $eligible_line ) {
492 + $total_qualifying_units += $eligible_line['eligible_qty'];
493 + }
494 +
495 + $discountable_units = $this->grouped_discount_budget( $rule, $total_qualifying_units );
496 +
497 + // Hand out the discountable units across the eligible lines in cart order.
498 + foreach ( $eligible_lines as $eligible_line ) {
499 + if ( $discountable_units <= 0 ) {
500 + break;
501 + }
502 +
503 + $line_units = (int) min( $eligible_line['eligible_qty'], $discountable_units );
504 +
505 + if ( $line_units <= 0 ) {
506 + continue;
507 + }
508 +
509 + $discountable_units -= $line_units;
510 +
511 + $item = $eligible_line['item'];
512 + $item['disco_forced_qty'] = $line_units;
513 +
514 + $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
515 + }
516 + }
517 +
518 + return $discounts;
519 + }
520 +
521 + /**
522 + * Number of units a pooled rule should actually discount.
523 + *
524 + * BOGO rules (with `get_quantity`) reward `get_quantity × bundles`; every
525 + * other rule discounts the whole qualifying pool.
526 + *
527 + * @param array $rule Discount rule.
528 + * @param int $total_qualifying Pooled qualifying quantity.
529 + */
530 + private function grouped_discount_budget( array $rule, int $total_qualifying ): int {
531 + $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
532 +
533 + if ( $reward_quantity <= 0 ) {
534 + // Bundle / Bulk: discount every qualifying unit.
535 + return $total_qualifying;
536 + }
537 +
538 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
539 + $bundle_count = $minimum_quantity > 0 ? (int) floor( $total_qualifying / $minimum_quantity ) : 1; // phpcs:ignore
540 +
541 + if ( $bundle_count < 1 ) {
542 + $bundle_count = 1;
543 + }
544 +
545 + // BOGO reward: get_quantity per qualifying bundle, never more than the pool.
546 + return (int) min( $reward_quantity * $bundle_count, $total_qualifying );
547 + }
548 +
549 + /**
550 + * Populate the discount array for a single cart line + rule pair.
551 + *
552 + * Shared by both the per-line (`separate`) path and the grouped
553 + * (`combined` / `variations`) path so the emitted discount structure is
554 + * identical regardless of counting mode.
555 + *
556 + * @param array $discounts Discount array, by reference.
557 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
558 + * @param array $rule Single discount rule.
559 + * @param array $item Cart item (may carry `disco_forced_qty`).
560 + * @param \WC_Cart $cart Cart Object.
561 + * @param int $total_applicable_qty Combined qualifying quantity.
562 + */
563 + private function apply_rule_to_discount( array &$discounts, Config $campaign, array $rule, array $item, \WC_Cart $cart, int $total_applicable_qty ): void {//phpcs:ignore
564 + $product = $item['data'];
565 + $rule_key = md5( microtime() . wp_rand() );
566 + $effective_product_id = $this->get_cart_item_id( $item );
567 +
568 + // Set the discount array.
569 + $discounts[ $effective_product_id ]['original_price'] = $product->get_price();
570 + $discounts[ $effective_product_id ]['cart_item_key'] = $item['key'];
571 +
572 + // Set discount applies to
573 + $discounts[ $effective_product_id ]['discount_applies_to'][ $rule_key ] = CalcFactory::discount_applies_to( $rule, $campaign );
574 +
575 + // Set Discounts.
576 + if ( $rule['discount_type'] === 'free' ) {
577 + $discounted_amount = array(
578 + 'price' => $product->get_price(),
579 + 'discount' => 0,
580 + 'line_subtotal' => $product->get_price() * $item['quantity'],
581 + 'discounted_quantities' => 0,
582 + );
583 + $discounts[ $effective_product_id ]['free'] = true;
584 + $discounts[ $effective_product_id ]['get_ids'] = $rule['get_ids'];
585 +
586 + if ( 'products' === $campaign->get_bogo_type() ) {
587 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
432 588 /**
433 - * Recursive BOGO (any bogo type): base the free quantity on the
434 - * combined qualifying quantity instead of this single line, so a
435 - * cart whose qualifying quantity totals 4 grants 4 free items.
589 + * Combined / variations: the buy-X quantity is pooled across
590 + * the cart, so the reward is one bundle (non-recursive) or one
591 + * per bundle (recursive) of the pooled total — not per line.
436 592 */
437 - if ( 'yes' === $rule['recursive'] && (int) $rule['min'] > 0 ) {
438 - $discounts[ $id ]['get_qty'] = (int) floor( $total_applicable_qty / (int) $rule['min'] ) * (int) $rule['get_quantity'];
593 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
594 + $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
595 +
596 + if ( 'yes' === $rule['recursive'] && $minimum_quantity > 0 ) {
597 + $discounts[ $effective_product_id ]['get_qty'] = (int) floor( $total_applicable_qty / $minimum_quantity ) * $reward_quantity;
598 + } else {
599 + $discounts[ $effective_product_id ]['get_qty'] = $reward_quantity;
439 600 }
440 601 } else {
441 - $discounted_amount = CalcFactory::get_discount( $rule, $item, $cart, $campaign );
442 - $discounts[ $id ]['free'] = false;
443 - $discounts[ $id ]['get_ids'] = array();
444 - $discounts[ $id ]['get_qty'] = 0;
602 + /**
603 + * Separate: X = all products. Every cart product grants the
604 + * reward of each tier its own quantity matches (summed), so
605 + * two products matching different tiers each contribute.
606 + */
607 + $discounts[ $effective_product_id ]['get_qty'] = $this->get_products_bogo_free_quantity( $campaign, $cart );
445 608 }
609 + } else {
610 + /**
611 + * BuyXGetX ('all'): per line, recursive → floor( line qty / min ) ×
612 + * get_quantity. Category BOGO never reaches this — its free items are
613 + * owned by {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}.
614 + */
615 + $discounts[ $effective_product_id ]['get_qty'] = CalcFactory::get_free_quantity( $rule, $item );
616 + }
617 + } else {
618 + $discounted_amount = CalcFactory::get_discount( $rule, $item, $cart, $campaign );
619 + $discounts[ $effective_product_id ]['free'] = false;
620 + $discounts[ $effective_product_id ]['get_ids'] = array();
621 + $discounts[ $effective_product_id ]['get_qty'] = 0;
622 + }
446 623
447 - // $discounts[ $id ]['get_ids'] = array_values( $rule['get_ids'] );
624 + $discounts[ $effective_product_id ]['discount_types'][ $rule_key ] = $rule['discount_type'];
625 + $discounts[ $effective_product_id ]['intent'][ $rule_key ] = $campaign->get_discount_intent();
626 + $discounts[ $effective_product_id ]['prices'][ $rule_key ] = $discounted_amount['price'];
627 + $discounts[ $effective_product_id ]['discounts'][ $rule_key ] = $discounted_amount['discount'];
628 + $discounts[ $effective_product_id ]['subtotals'][ $rule_key ] = $discounted_amount['line_subtotal'];
629 + $discounts[ $effective_product_id ]['discounted_quantities'][ $rule_key ] = $discounted_amount['discounted_quantities'];
630 + $discounts[ $effective_product_id ]['quantities'][ $rule_key ] = $item['quantity'];
448 631
449 - $discounts[ $id ]['discount_types'][ $r ] = $rule['discount_type'];
450 - $discounts[ $id ]['intent'][ $r ] = $campaign->get_discount_intent();
451 - $discounts[ $id ]['prices'][ $r ] = $discounted_amount['price'];
452 - $discounts[ $id ]['discounts'][ $r ] = $discounted_amount['discount'];
453 - $discounts[ $id ]['subtotals'][ $r ] = $discounted_amount['line_subtotal'];
454 - $discounts[ $id ]['discounted_quantities'][ $r ] = $discounted_amount['discounted_quantities'];
455 - $discounts[ $id ]['quantities'][ $r ] = $item['quantity'];
456 - $offers = $this->get_item_offers( $rule );
457 -
458 - $discounts[ $id ]['offers'][ $r ] = $offers;
459 - }//end foreach
460 - }//end foreach
461 -
462 - return $discounts;
632 + $discounts[ $effective_product_id ]['offers'][ $rule_key ] = $this->get_item_offers( $rule );
463 633 }
464 634
465 635 /**
466 636 * Prepare discounts for cart items.
@@ -473,10 +643,9 @@
473 643 if ( empty( $intents ) ) {
474 644 return false;
475 645 }
476 646
477 - $discounts = array();
478 - $applied_campaign_id = 0;
647 + $discounts = array();
479 648
480 649 // Loop through the intents.
481 650 foreach ( $intents as $intent ) {
482 651 /**
@@ -483,13 +652,9 @@
483 652 * Get a discount limit form campaign.
484 653 *
485 654 * Compare with total product meta and apply discount
486 655 */
487 - $discount_limit = $intent->campaign->discount_max_user;
488 - $applied_campaign_id = $intent->campaign->id;
489 - $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
490 -
491 - if ( ! empty( $discount_limit ) && ( $discount_limit >= 0 && $total_applied_campaign >= $discount_limit ) ) {
656 + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
492 657 continue;
493 658 }
494 659
495 660 $items = $this->get_items_for_discount( $cart, $intent->campaign );
@@ -499,9 +664,18 @@
499 664 continue;
500 665 }
501 666
502 667 foreach ( $get_discounts as $item_id => $discount ) {
668 + /**
669 + * Keep the campaign alongside its amount at the same index.
670 + *
671 + * Staging cannot happen yet: several campaigns can offer an amount
672 + * for the same item and only one of them survives the min/max
673 + * reduction below. Crediting every campaign here would consume the
674 + * usage limit of campaigns whose discount never reached the cart.
675 + */
503 676 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
677 + $discounts[ $item_id ]['campaigns'][] = (int) $intent->campaign->id;
504 678 }
505 679 }
506 680
507 681 // Check if the discounts are empty.
@@ -508,15 +682,26 @@
508 682 if ( empty( $discounts ) ) {
509 683 return false;
510 684 }
511 685
686 + $applied_campaign_ids = array();
687 +
512 688 // Get the min or max discount amount for each item.
513 689 foreach ( $discounts as $item_id => $discount ) {
514 - $discounts[ $item_id ] = $this->min_max_average( $discount['discounts'] );
690 + $winning_amount = $this->min_max_average( $discount['discounts'] );
691 + $winning_index = array_search( $winning_amount, $discount['discounts'], true );
692 +
693 + if ( false !== $winning_index && isset( $discount['campaigns'][ $winning_index ] ) ) {
694 + $applied_campaign_ids[ $discount['campaigns'][ $winning_index ] ] = true;
695 + }
696 +
697 + $discounts[ $item_id ] = $winning_amount;
515 698 }
516 699
517 - // Set applied campaign on DiscountLimit class.
518 - ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
700 + // Stage only the campaigns that actually won an item.
701 + foreach ( array_keys( $applied_campaign_ids ) as $applied_campaign_id ) {
702 + ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
703 + }
519 704
520 705 return $discounts;
521 706 }
522 707
@@ -525,11 +710,11 @@
525 710 *
526 711 * @param array $intents Intents.
527 712 * @param \WC_Cart $cart Cart.
528 713 * @param array $discounts Discounts.
529 - * @return int|null
714 + * @return int|string|null
530 715 */
531 - public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {
716 + public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {//phpcs:ignore
532 717 foreach ( $intents as $intent ) {
533 718 if (
534 719 $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
535 720 $intent->campaign->get_bogo_type() !== 'categories'
@@ -536,15 +721,25 @@
536 721 ) {
537 722 continue;
538 723 }
539 724
725 + // Collect every eligible category item (in cart order) with its price,
726 + // then pick the reward item per the campaign's selection strategy.
727 + $candidates = array();
728 +
540 729 foreach ( $cart->get_cart() as $item ) {
541 - $item_id = $this->get_cart_item_id( $item );
730 + $cart_item_id = $this->get_cart_item_id( $item );
542 731
543 - if ( isset( $discounts[ $item_id ] ) && $discounts[ $item_id ] > 0 ) {
544 - return $item_id;
732 + if ( isset( $discounts[ $cart_item_id ] ) && $discounts[ $cart_item_id ] > 0 ) { // phpcs:ignore
733 + $candidates[ $cart_item_id ] = CalcFactory::get_price( $item );
545 734 }
546 735 }
736 +
737 + if ( empty( $candidates ) ) {
738 + continue;
739 + }
740 +
741 + return $this->pick_reward_item( $candidates, $intent->campaign->get_free_item_selection() );
547 742 }
548 743
549 744 return null;
550 745 }
@@ -549,8 +744,34 @@
549 744 return null;
550 745 }
551 746
552 747 /**
748 + * Pick the reward cart-item id from eligible candidates.
749 + *
750 + * Honours the "Free Item Selection" strategy when more than one product
751 + * from the reward category is in the cart:
752 + * - `cart_order` (default) → the first eligible item in cart order.
753 + * - `lowest` → the lowest-priced eligible item.
754 + * - `highest` → the highest-priced eligible item.
755 + * Ties keep cart order (first match wins).
756 + *
757 + * @param array<int|string, float> $candidates Map of item id => price, in cart order.
758 + * @param string $selection Selection strategy.
759 + * @return int|string|null
760 + */
761 + private function pick_reward_item( array $candidates, string $selection ) {
762 + if ( 'lowest' === $selection ) {
763 + return array_keys( $candidates, min( $candidates ), true )[0];
764 + }
765 +
766 + if ( 'highest' === $selection ) {
767 + return array_keys( $candidates, max( $candidates ), true )[0];
768 + }
769 +
770 + return array_key_first( $candidates );
771 + }
772 +
773 + /**
553 774 * Prepare discounts for cart items.
554 775 *
555 776 * @param array $intents Intents.
556 777 * @param \WC_Cart $cart Cart.
@@ -560,12 +781,11 @@
560 781 if ( empty( $intents ) ) {
561 782 return false;
562 783 }
563 784
564 - $discounts = array();
785 + $discounts = array();
786 + $candidate_campaign_ids = array();
565 787
566 - $applied_campaign_id = 0;
567 -
568 788 // Loop through the intents.
569 789 foreach ( $intents as $intent ) {
570 790 /**
571 791 * Get a discount limit form campaign.
@@ -571,13 +791,9 @@
571 791 * Get a discount limit form campaign.
572 792 *
573 793 * Compare with total product meta and apply discount
574 794 */
575 - $discount_limit = $intent->campaign->discount_max_user;
576 - $applied_campaign_id = $intent->campaign->id;
577 - $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id );
578 -
579 - if ( ! empty( $discount_limit ) && ( $discount_limit >= 0 && $total_applied_campaign >= $discount_limit ) ) {
795 + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
580 796 continue;
581 797 }
582 798
583 799 $items = $this->get_items_for_discount( $cart, $intent->campaign );
@@ -586,14 +802,26 @@
586 802 if ( empty( $get_discounts ) ) {
587 803 continue;
588 804 }
589 805
806 + /**
807 + * Collect the campaign now, stage it later.
808 + *
809 + * What this method finally grants is decided well below: the rewards
810 + * are unioned across campaigns and a BuyXGetY (products) campaign can
811 + * then override the whole result authoritatively. Crediting a campaign
812 + * here would consume its usage limit even when the final result grants
813 + * it nothing.
814 + */
815 + $candidate_campaign_ids[] = (int) $intent->campaign->id;
816 +
590 817 foreach ( $get_discounts as $item_id => $discount ) {
591 - $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
592 - $discounts[ $item_id ]['free'] = $discount['free'];
593 - $discounts[ $item_id ]['get_ids'] = $discount['get_ids'];
594 - $discounts[ $item_id ]['get_qty'] = $discount['get_qty'];
595 - $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type();
818 + $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); // phpcs:ignore
819 + $discounts[ $item_id ]['free'] = $discount['free']; // phpcs:ignore
820 + $discounts[ $item_id ]['get_ids'] = $discount['get_ids']; // phpcs:ignore
821 + $discounts[ $item_id ]['get_qty'] = $discount['get_qty']; // phpcs:ignore
822 + $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type(); // phpcs:ignore
823 + $discounts[ $item_id ]['free_item_selection'] = $intent->campaign->get_free_item_selection(); // phpcs:ignore
596 824 }
597 825 }
598 826
599 827 // Check if the discounts are empty.
@@ -600,29 +828,309 @@
600 828 if ( empty( $discounts ) ) {
601 829 return false;
602 830 }
603 831
604 - // Get the min or max discount amount for each item.
832 + /**
833 + * Aggregate the per-item free rewards into the flat shape the cart hook
834 + * consumes. Union every qualifying reward id (previously this overwrote
835 + * on each pass, so only the last cart item was ever granted its free
836 + * product) and keep a per-id quantity map for products whose free counts
837 + * differ (e.g. recursive BuyXGetX).
838 + */
839 + $reward_product_ids = array();
840 + $reward_quantity_map = array();
841 + $has_free_items = false;
842 + $bogo_type = 'products';
843 + $free_item_selection = 'cart_order';
844 +
605 845 foreach ( $discounts as $item_id => $discount ) {
606 - $discounts['discount'] = $this->min_max_average( $discount['discounts'] );
607 - $discounts['free'] = $discount['free'];
608 - $discounts['get_ids'] = $discount['get_ids'] ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); //@phpcs:ignore
609 - $discounts['get_qty'] = $discount['get_qty'];
610 - $discounts['bogo_type'] = $discount['bogo_type'] ?? 'products';
846 + $has_free_items = $discount['free'];
847 + $bogo_type = $discount['bogo_type'] ?? 'products';
848 + $free_item_selection = $discount['free_item_selection'] ?? 'cart_order';
849 +
850 + $rule_reward_ids = ! empty( $discount['get_ids'] ) ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); // phpcs:ignore
851 +
852 + foreach ( $rule_reward_ids as $reward_product_id ) {
853 + $reward_product_id = (int) $reward_product_id; // phpcs:ignore
854 + $reward_product_ids[] = $reward_product_id; // phpcs:ignore
855 + $reward_quantity_map[ $reward_product_id ] = (int) $discount['get_qty']; // phpcs:ignore
856 + }
611 857 }
612 858
613 - // Set applied campaign on DiscountLimit class.
614 - ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
859 + $reward_product_ids = array_values( array_unique( $reward_product_ids ) );
615 860
861 + $discounts['discount'] = 0.0;
862 + $discounts['free'] = $has_free_items;
863 + $discounts['get_ids'] = $reward_product_ids;
864 + $discounts['get_qty'] = empty( $reward_quantity_map ) ? 0 : max( $reward_quantity_map ); // phpcs:ignore
865 + $discounts['get_qty_map'] = $reward_quantity_map;
866 + $discounts['bogo_type'] = $bogo_type;
867 + $discounts['free_item_selection'] = $free_item_selection;
868 +
869 + /**
870 + * BuyXGetY (products): replace the flat aggregation with an authoritative
871 + * reward map keyed by the tier's own get product, so each cart product
872 + * that qualifies grants its tier's reward.
873 + *
874 + * Category BOGO is not handled here — {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}
875 + * owns its reward selection, buy reservation and cart reconciliation.
876 + */
877 + foreach ( $intents as $intent ) {
878 + $bogo_type = $intent->campaign->get_bogo_type();
879 +
880 + if ( 'BuyXGetY' !== $intent->campaign->get_discount_intent() || 'products' !== $bogo_type ) {
881 + continue;
882 + }
883 +
884 + // Only free-type rules grant free items; a percent/fixed BOGO must not
885 + // fabricate free products here.
886 + $rules = $intent->campaign->get_discount_rules();
887 + $first_rule = ( is_array( $rules ) && isset( $rules[0] ) ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
888 +
889 + if ( ! isset( $first_rule['discount_type'] ) || 'free' !== $first_rule['discount_type'] ) {
890 + continue;
891 + }
892 +
893 + $reward_map = $this->compute_bogo_products_free_map( $intent->campaign, $cart );
894 +
895 + // Always authoritative: an empty map means no qualifying (non-reward)
896 + // purchase, so no free items — do not fall back to the flat count.
897 + $discounts['get_ids'] = array_keys( $reward_map ); // phpcs:ignore
898 + $discounts['get_qty_map'] = $reward_map; // phpcs:ignore
899 + // Each get-product is freed its own quantity.
900 + $discounts['get_qty'] = empty( $reward_map ) ? 0 : max( $reward_map ); // phpcs:ignore
901 + $discounts['free'] = ! empty( $reward_map ); // phpcs:ignore
902 + $discounts['bogo_type'] = $bogo_type;
903 + $discounts['free_item_selection'] = $intent->campaign->get_free_item_selection();
904 +
905 + /**
906 + * This branch is authoritative for the whole result, so the campaign
907 + * that owns it is the only one that can be credited.
908 + */
909 + $candidate_campaign_ids = array( (int) $intent->campaign->id );
910 + }
911 +
912 + /**
913 + * Stage the campaigns only once the result is final, and only when it
914 + * actually grants free items.
915 + */
916 + if ( ! empty( $discounts['free'] ) ) {
917 + foreach ( array_unique( $candidate_campaign_ids ) as $applied_campaign_id ) {
918 + ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
919 + }
920 + }
921 +
616 922 return $discounts;
617 923 }
618 924
619 925 /**
926 + * Authoritative free Y map for a BuyXGetY (products) campaign.
927 + *
928 + * Returns get-product id => free quantity, attributing each tier's reward to
929 + * that tier's own get-products:
930 + * - `separate` → each cart line credits every tier whose range
931 + * its own quantity matches ([min,max]); rewards
932 + * sum, and each tier credits its own get-products.
933 + * - `combined` → one pool over every applicable line; the highest
934 + * tier it reaches credits its get-products once.
935 + * - `variations` → one pool per parent product (all variations of
936 + * the same variable count together, and a simple
937 + * product is its own pool); every pool that
938 + * reaches a tier credits that tier's get-products,
939 + * so two qualifying parents grant two rewards.
940 + * Recursive tiers multiply by floor(qty/min) (line qty for separate, pool qty
941 + * for combined / variations).
942 + *
943 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
944 + * @param \WC_Cart $cart Cart Object.
945 + * @return array<int, int>
946 + */
947 + private function compute_bogo_products_free_map( Config $campaign, \WC_Cart $cart ): array {//phpcs:ignore
948 + $rules = $campaign->get_discount_rules();
949 +
950 + if ( ! is_array( $rules ) || empty( $rules ) ) {
951 + return array();
952 + }
953 +
954 + $reward_map = array();
955 +
956 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
957 + foreach ( $this->get_bogo_buy_pool_quantities( $cart, $campaign ) as $pool_quantity ) {
958 + $this->credit_bogo_pool_reward( $reward_map, $rules, $pool_quantity );
959 + }
960 +
961 + return $reward_map;
962 + }
963 +
964 + /**
965 + * Separate: build the buy quantity per product, then each product earns
966 + * the highest tier its quantity qualifies for (min <= qty). The upper
967 + * `max` marks where the next tier begins, not a cut-off, so a quantity
968 + * above the top tier still earns it and a gap earns the tier below.
969 + * Free lines never count.
970 + */
971 + $buy_quantity_per_product = array();
972 +
973 + foreach ( $cart->get_cart() as $item ) {
974 + if ( ! empty( $item['is_free_product'] ) ) {
975 + continue;
976 + }
977 +
978 + $effective_product_id = $item['product_id'];
979 +
980 + if ( ! empty( $item['variation_id'] ) ) {
981 + $effective_product_id = $item['variation_id'];
982 + }
983 +
984 + if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
985 + continue;
986 + }
987 +
988 + $product = wc_get_product( $effective_product_id );
989 +
990 + if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
991 + continue;
992 + }
993 +
994 + $buy_quantity_per_product[ (int) $effective_product_id ] = ( $buy_quantity_per_product[ (int) $effective_product_id ] ?? 0 ) + (int) $item['quantity'];
995 + }
996 +
997 + foreach ( $buy_quantity_per_product as $quantity ) {
998 + $qualifying_rule = null;
999 + $qualifying_minimum = -1;
1000 +
1001 + foreach ( $rules as $rule ) {
1002 + $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1003 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1004 + $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1005 +
1006 + if ( $minimum_quantity <= 0 || $reward_quantity <= 0 || empty( $rule['get_ids'] ) ) {
1007 + continue;
1008 + }
1009 +
1010 + if ( $quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1011 + $qualifying_rule = $rule;
1012 + $qualifying_minimum = $minimum_quantity;
1013 + }
1014 + }
1015 +
1016 + if ( null === $qualifying_rule ) {
1017 + continue;
1018 + }
1019 +
1020 + $minimum_quantity = absint( $qualifying_rule['min'] );
1021 + $reward_quantity = (int) $qualifying_rule['get_quantity'];
1022 + $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1023 + $reward_total = ( $is_recursive && $minimum_quantity > 0 ) ? ( (int) floor( $quantity / $minimum_quantity ) * $reward_quantity ) : $reward_quantity; // phpcs:ignore
1024 +
1025 + if ( $reward_total > 0 ) { // phpcs:ignore
1026 + foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1027 + $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $reward_total;
1028 + }
1029 + }
1030 + }
1031 +
1032 + return $reward_map;
1033 + }
1034 +
1035 + /**
1036 + * Pooled buy quantities for combined / variations, excluding free lines.
1037 + *
1038 + * `combined` returns a single pool over every applicable line. `variations`
1039 + * returns one pool per parent product, so variations of the same variable
1040 + * product count together while different parents stay apart — the same
1041 + * grouping {@see QuantityCounter} applies to the buy-X gate.
1042 + *
1043 + * @param \WC_Cart $cart Cart Object.
1044 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1045 + * @return array<string, int> Pool key => pooled quantity.
1046 + */
1047 + private function get_bogo_buy_pool_quantities( \WC_Cart $cart, Config $campaign ): array {//phpcs:ignore
1048 + $pool_quantities = array();
1049 + $pool_per_parent = 'variations' === $campaign->get_count_quantity_as();
1050 + $cart_items = $cart->get_cart();
1051 +
1052 + if ( ! is_array( $cart_items ) ) {
1053 + return $pool_quantities;
1054 + }
1055 +
1056 + foreach ( $cart_items as $item ) {
1057 + if ( ! empty( $item['is_free_product'] ) ) {
1058 + continue;
1059 + }
1060 +
1061 + $effective_product_id = $item['product_id'];
1062 +
1063 + if ( ! empty( $item['variation_id'] ) ) {
1064 + $effective_product_id = $item['variation_id'];
1065 + }
1066 +
1067 + if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1068 + continue;
1069 + }
1070 +
1071 + $product = wc_get_product( $effective_product_id );
1072 +
1073 + if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1074 + continue;
1075 + }
1076 +
1077 + $pool_key = $pool_per_parent ? 'parent_' . (int) $item['product_id'] : 'all'; // phpcs:ignore
1078 +
1079 + $pool_quantities[ $pool_key ] = ( $pool_quantities[ $pool_key ] ?? 0 ) + (int) $item['quantity'];
1080 + }
1081 +
1082 + return $pool_quantities;
1083 + }
1084 +
1085 + /**
1086 + * Credit one pool's reward to the get-products of the tier it qualifies for.
1087 + *
1088 + * The winning tier is the one with the highest `min` the pool reaches; a
1089 + * recursive tier multiplies its reward by the number of complete sets.
1090 + *
1091 + * @param array $reward_map Reward map (get-product id => quantity), by reference.
1092 + * @param array $rules Discount rules.
1093 + * @param int $pool_quantity Pooled buy quantity.
1094 + */
1095 + private function credit_bogo_pool_reward( array &$reward_map, array $rules, int $pool_quantity ): void {//phpcs:ignore
1096 + $qualifying_rule = null;
1097 + $qualifying_minimum = -1;
1098 +
1099 + foreach ( $rules as $rule ) {
1100 + $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1101 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1102 +
1103 + if ( $minimum_quantity > 0 && $pool_quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1104 + $qualifying_rule = $rule;
1105 + $qualifying_minimum = $minimum_quantity;
1106 + }
1107 + }
1108 +
1109 + if ( null === $qualifying_rule || empty( $qualifying_rule['get_ids'] ) ) {
1110 + return;
1111 + }
1112 +
1113 + $minimum_quantity = absint( $qualifying_rule['min'] );
1114 + $reward_quantity = ! empty( $qualifying_rule['get_quantity'] ) ? (int) $qualifying_rule['get_quantity'] : 0; // phpcs:ignore
1115 + $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1116 + $quantity = ( $is_recursive && $minimum_quantity > 0 ) ? (int) floor( $pool_quantity / $minimum_quantity ) * $reward_quantity : $reward_quantity; // phpcs:ignore
1117 +
1118 + if ( $quantity <= 0 ) {
1119 + return;
1120 + }
1121 +
1122 + foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1123 + $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $quantity;
1124 + }
1125 + }
1126 +
1127 + /**
620 1128 * Is it a bogo campaign?
621 1129 *
622 1130 * @param \Disco\App\Utility\Config $campaign Campaign Config.
623 1131 */
624 - public function is_bogo( Config $campaign ): bool {
1132 + public function is_bogo( Config $campaign ): bool { // phpcs:ignore
625 1133 return in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true );
626 1134 }
627 1135
628 1136 /**
@@ -651,8 +1159,111 @@
651 1159 return min( $discounts );
652 1160 }
653 1161
654 1162 /**
1163 + * Strip the embedded tax portion from a discount amount.
1164 + *
1165 + * When WooCommerce is set to enter prices inclusive of tax, the discount
1166 + * amount computed by the intents also carries that tax (e.g. a 50 discount
1167 + * becomes 55 at 10% tax). The cart fee is added as non-taxable, so the tax
1168 + * must be removed here to keep the net discount correct.
1169 + *
1170 + * When a cart is supplied, the embedded-tax portion is derived from the
1171 + * cart's own subtotal and subtotal tax rather than assuming the whole
1172 + * amount carries the standard rate. This keeps mixed carts correct where
1173 + * some products are taxable and others are not (or use different tax
1174 + * classes): non-taxable products add to the subtotal but contribute zero
1175 + * tax, so the effective inclusive-tax ratio drops accordingly.
1176 + *
1177 + * @since 1.3.54
1178 + * @param float $amount Tax-inclusive discount amount.
1179 + * @param \WC_Cart|null $cart Optional cart used to derive the effective tax ratio.
1180 + * @return float Tax-exclusive discount amount.
1181 + */
1182 + public function get_discount_exclude_tax( float $amount, $cart = null ): float {
1183 + if ( ! function_exists( 'wc_prices_include_tax' ) || ! wc_prices_include_tax() ) {
1184 + return $amount;
1185 + }
1186 +
1187 + if ( ! class_exists( 'WC_Tax' ) ) {
1188 + return $amount;
1189 + }
1190 +
1191 + // Prefer a product-derived inclusive-tax ratio so mixed carts strip
1192 + // only the tax actually embedded. The discount base is built from each
1193 + // product's tax-inclusive price (see CalcFactory::get_price), so we
1194 + // rebuild the same base excluding tax per product. wc_get_price_excluding_tax
1195 + // respects each product's own tax status and class: a non-taxable
1196 + // product returns its price unchanged and contributes zero embedded tax.
1197 + if ( $cart instanceof \WC_Cart && function_exists( 'wc_get_price_excluding_tax' ) ) {
1198 + return $this->strip_tax_using_cart_ratio( $amount, $cart );
1199 + }
1200 +
1201 + // Fallback: rates for the standard tax class at the customer location.
1202 + $rates = \WC_Tax::get_rates( '' );
1203 +
1204 + if ( empty( $rates ) ) {
1205 + return $amount;
1206 + }
1207 +
1208 + // Tax embedded inside the inclusive amount, then remove it.
1209 + $tax_total = array_sum( \WC_Tax::calc_inclusive_tax( $amount, $rates ) );
1210 +
1211 + return $amount - $tax_total;
1212 + }
1213 +
1214 + /**
1215 + * Strip embedded tax from a discount using the cart's own inclusive-tax ratio.
1216 + *
1217 + * Rebuilds the discount base (sum of tax-inclusive prices) and its
1218 + * tax-exclusive counterpart per product, then scales the discount by the
1219 + * excl/incl ratio. Non-taxable products contribute equally to both sums,
1220 + * so their share keeps no tax stripped.
1221 + *
1222 + * @param float $amount Tax-inclusive discount amount.
1223 + * @param \WC_Cart $cart Cart used to derive the ratio.
1224 + * @return float Tax-exclusive discount amount.
1225 + */
1226 + private function strip_tax_using_cart_ratio( float $amount, \WC_Cart $cart ): float {
1227 + $incl_sum = 0.0;
1228 + $excl_sum = 0.0;
1229 + $cart_items = $cart->get_cart();
1230 +
1231 + if ( ! is_array( $cart_items ) ) {
1232 + $cart_items = array();
1233 + }
1234 +
1235 + foreach ( $cart_items as $cart_item ) {
1236 + if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof \WC_Product ) {
1237 + continue;
1238 + }
1239 +
1240 + $product = $cart_item['data'];
1241 + $qty = (float) ( $cart_item['quantity'] ?? 1 );
1242 + $price_incl = (float) $product->get_price();
1243 + $price_excl = (float) wc_get_price_excluding_tax( $product, array( 'price' => $price_incl ) );
1244 +
1245 + $incl_sum += $price_incl * $qty;
1246 + $excl_sum += $price_excl * $qty;
1247 + }
1248 +
1249 + // No priced items in the cart -> nothing to strip.
1250 + if ( $incl_sum <= 0 ) {
1251 + return $amount;
1252 + }
1253 +
1254 + $net = $amount * $excl_sum / $incl_sum;
1255 +
1256 + $decimals = 2;
1257 +
1258 + if ( function_exists( 'wc_get_price_decimals' ) ) {
1259 + $decimals = wc_get_price_decimals();
1260 + }
1261 +
1262 + return round( $net, $decimals );
1263 + }
1264 +
1265 + /**
655 1266 * Total qualifying quantity for recursive BOGO.
656 1267 *
657 1268 * Sums the quantity of every cart item that passes the campaign's
658 1269 * few-products / "all" filter (product_is_applicable) and its conditions
@@ -699,8 +1310,97 @@
699 1310 return $total;
700 1311 }
701 1312
702 1313 /**
1314 + * Whether a campaign is a free products BOGO evaluated per product.
1315 + *
1316 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1317 + * @param array $rules Discount rules.
1318 + */
1319 + private function is_per_product_free_bogo( Config $campaign, array $rules ): bool {
1320 + if ( 'products' !== $campaign->get_bogo_type() ) {
1321 + return false;
1322 + }
1323 +
1324 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
1325 + return false;
1326 + }
1327 +
1328 + $first_rule = isset( $rules[0] ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
1329 +
1330 + return isset( $first_rule['discount_type'] ) && 'free' === $first_rule['discount_type'];
1331 + }
1332 +
1333 + /**
1334 + * Total free Y quantity for a products BuyXGetY campaign in `separate` mode.
1335 + *
1336 + * X is every applicable product. Each cart line grants the reward of every
1337 + * rule (tier) whose range its own quantity falls in — `qty` within
1338 + * `[min, max]` (no max = min-only) — summed across matching tiers, and ×
1339 + * floor(qty/min) when the tier is recursive. The Y product counts as an X
1340 + * line too. Summing across all qualifying lines gives the free Y count, so
1341 + * two products each matching a different tier grant each tier's reward.
1342 + *
1343 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1344 + * @param \WC_Cart $cart Cart Object.
1345 + */
1346 + private function get_products_bogo_free_quantity( Config $campaign, \WC_Cart $cart ): int {//phpcs:ignore
1347 + $rules = $campaign->get_discount_rules();
1348 + $cart_items = $cart->get_cart();
1349 +
1350 + if ( ! is_array( $rules ) || ! is_array( $cart_items ) ) {
1351 + return 0;
1352 + }
1353 +
1354 + $free_quantity_total = 0;
1355 +
1356 + foreach ( $cart_items as $item ) {
1357 + if ( ! empty( $item['is_free_product'] ) ) {
1358 + continue;
1359 + }
1360 +
1361 + $effective_product_id = $item['product_id'];
1362 +
1363 + if ( ! empty( $item['variation_id'] ) ) {
1364 + $effective_product_id = $item['variation_id'];
1365 + }
1366 +
1367 + if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1368 + continue;
1369 + }
1370 +
1371 + $product = wc_get_product( $effective_product_id );
1372 +
1373 + if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1374 + continue;
1375 + }
1376 +
1377 + $line_quantity = (int) $item['quantity'];
1378 +
1379 + foreach ( $rules as $rule ) {
1380 + $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1381 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1382 + $maximum_quantity = ! empty( $rule['max'] ) ? absint( $rule['max'] ) : 0; // phpcs:ignore
1383 + $get_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1384 + $recursive = ! empty( $rule['recursive'] ) && 'yes' === $rule['recursive']; // phpcs:ignore
1385 +
1386 + if ( $minimum_quantity <= 0 || $get_quantity <= 0 || $line_quantity < $minimum_quantity ) {
1387 + continue;
1388 + }
1389 +
1390 + // Respect the tier's upper bound so each product matches its tier.
1391 + if ( $maximum_quantity > 0 && $line_quantity > $maximum_quantity && 'yes' !== $rule['recursive'] ) {
1392 + continue;
1393 + }
1394 +
1395 + $free_quantity_total += $recursive ? ( (int) floor( $line_quantity / $minimum_quantity ) * $get_quantity ) : $get_quantity; // phpcs:ignore
1396 + }
1397 + }
1398 +
1399 + return $free_quantity_total;
1400 + }
1401 +
1402 + /**
703 1403 * Get Offer Label.
704 1404 *
705 1405 * @param array $rule Discount Rule.
706 1406 */
@@ -850,9 +1550,56 @@
850 1550 *
851 1551 * @param \Disco\App\Utility\Config $campaign Campaign Config.
852 1552 * @param array $rule Discount Rules.
853 1553 */
1554 + /**
1555 + * Pick the single BuyXGetY tier that applies for the current cart.
1556 + *
1557 + * Tiers are ranked by `min`: the winner is the qualifying rule with the
1558 + * highest `min` that the buy quantity still meets (the broken upper `max` is
1559 + * ignored — a rule qualifies on `qty >= min`, mirroring the gate). On a tie,
1560 + * the later rule wins. Returns a list with 0 or 1 rule.
1561 + *
1562 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1563 + * @param array $rules All discount rules.
1564 + */
1565 + private function select_bogo_tier_rule( Config $campaign, array $rules ): array {
1566 + $qualifying_rule = null;
1567 + $qualifying_minimum = -1;
1568 +
1569 + foreach ( $rules as $rule ) {
1570 + $rule_array = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1571 +
1572 + // Rule only counts if the cart meets its buy-X threshold.
1573 + if ( ! $this->verify_xproduct_cart_rule( $campaign, $rule_array ) ) {
1574 + continue;
1575 + }
1576 +
1577 + $minimum_quantity = isset( $rule_array['min'] ) ? absint( $rule_array['min'] ) : 0; // phpcs:ignore
1578 +
1579 + if ( $minimum_quantity >= $qualifying_minimum ) { // phpcs:ignore
1580 + $qualifying_minimum = $minimum_quantity;
1581 + $qualifying_rule = $rule;
1582 + }
1583 + }
1584 +
1585 + return null === $qualifying_rule ? array() : array( $qualifying_rule ); // phpcs:ignore
1586 + }
1587 +
854 1588 private function verify_xproduct_cart_rule( Config $campaign, array $rule ): bool { //phpcs:ignore
1589 + /**
1590 + * "Count Quantity As" combined / variations: pool the buy-X quantity
1591 + * across cart lines instead of requiring a single line to reach `min`,
1592 + * so a mixed cart (e.g. 1 + 1 of different products) can satisfy a
1593 + * "buy 2" threshold. QuantityCounter honours the same min / max /
1594 + * recursive semantics used elsewhere.
1595 + */
1596 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) { // phpcs:ignore
1597 + $quantity_counter = new QuantityCounter;
1598 +
1599 + return ! empty( $quantity_counter->get_eligible_units( WC()->cart, $campaign, $rule ) );
1600 + }
1601 +
855 1602 $cart = WC()->cart->get_cart();
856 1603
857 1604 foreach ( $cart as $item ) {
858 1605 // Skip free products - they must not count towards the BOGO buy quantity,