PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.20
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.20
1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 All 184 releases
← All changes | app/Intents/IntentHelper.php +892 -94 1.3.51 → 1.4.20 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,
@@ -250,17 +251,39 @@
250 251 public function calculate_discount( $cost, $discount_type, $discount_value ) {
251 252 $cost = (float) $cost;
252 253 $discount_value = (float) $discount_value;
253 254
254 - if ( 'percent' === $discount_type ) {
255 - $cost = $cost * $discount_value / 100;
256 - }
255 + switch ( $discount_type ) {
256 + case 'percent':
257 + return $cost * $discount_value / 100;
257 258
258 - if ( 'fixed' === $discount_type || 'fixed_per_product' === $discount_type ) {
259 - $cost = $discount_value;
260 - }
259 + case 'fixed':
260 + case 'fixed_per_product':
261 + return $discount_value;
261 262
262 - return $cost;
263 + case 'free':
264 + // The whole cost comes off, which is what makes the item free.
265 + return $cost;
266 +
267 + default:
268 + /**
269 + * Anything else discounts nothing.
270 + *
271 + * This used to fall through and return $cost untouched, which the
272 + * callers then treat as the discount: ProductIntent hands it the
273 + * product price and CartIntent the cart subtotal, so an
274 + * unrecognised type gave away the product or the entire cart.
275 + *
276 + * Only Bulk, Bundle and BOGO route through the Calc classes,
277 + * where every type has an implementation; Product and Cart come
278 + * straight here, so the gap was reachable by any rule carrying a
279 + * type this method does not know: a campaign saved while
280 + * fixed_price and percent_per_product were still offered in the
281 + * builder, a REST write (discount_type inside discount_rules is
282 + * not validated), or a hand edited row.
283 + */
284 + return 0.0;
285 + }//end switch
263 286 }
264 287
265 288 /**
266 289 * Get the discounted price.
@@ -379,24 +402,58 @@
379 402 if ( empty( $items ) ) {
380 403 return $discounts;
381 404 }
382 405
406 + if ( ! is_array( $rules ) || empty( $rules ) ) {
407 + return $discounts;
408 + }
409 +
383 410 /**
411 + * BuyXGetY tiers are mutually exclusive: only one rule applies per
412 + * campaign, selected by the buy quantity. Without this, tiers rewarding
413 + * different get-products (e.g. rule 1 → product A, rule 2 → product B)
414 + * would all fire and stack. Every other intent already collapses
415 + * overlapping rules per product via min_max_average.
416 + *
417 + * Exception: a free products BOGO in `separate` mode is evaluated
418 + * per-product (each cart product uses the tier(s) its own quantity
419 + * matches), so all rules are kept and the reward is summed downstream.
420 + */
421 + if ( 'BuyXGetY' === $campaign->get_discount_intent() && ! $this->is_per_product_free_bogo( $campaign, $rules ) ) {
422 + $rules = $this->select_bogo_tier_rule( $campaign, $rules );
423 +
424 + if ( empty( $rules ) ) {
425 + return $discounts;
426 + }
427 + }
428 +
429 + /**
384 430 * Combined qualifying quantity for recursive BOGO, filtered by the
385 431 * conditions and few-products filters and excluding Disco free items.
386 432 */
387 433 $total_applicable_qty = $this->get_total_applicable_quantity( $cart, $campaign );
388 434
435 + /**
436 + * "Count Quantity As" combined / variations modes pool quantities across
437 + * cart line items before deciding eligibility, so they take a dedicated
438 + * path. The default `separate` mode keeps the original per-line behaviour.
439 + *
440 + * BOGO is excluded — count modes only apply to Bundle / Bulk:
441 + * - BuyXGetX matches purely on each line's own quantity, so it always
442 + * behaves as `separate` regardless of the count mode.
443 + * - BuyXGetY rewards the Y (get) product, not the counted X products, so
444 + * it stays on the per-item path and only its buy-X gate is pooled
445 + * (see {@see self::verify_xproduct_cart_rule()}).
446 + */
447 + if (
448 + ! in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true )
449 + && in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true )
450 + ) {
451 + return $this->get_grouped_item_discounts( $campaign, $cart, $rules, $total_applicable_qty );
452 + }
453 +
389 454 // Loop through the cart items.
390 455 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 456 // Loop through the discount rules.
400 457 foreach ( $rules as $rule ) {
401 458 if ( is_object( $rule ) ) {
402 459 $rule = (array) $rule;
@@ -406,61 +463,225 @@
406 463 if ( ! $this->verify_rules( $campaign, $item, $rule ) ) {
407 464 continue;
408 465 }
409 466
410 - $r = md5( microtime() . wp_rand() );
411 - $id = $this->get_cart_item_id( $item );
467 + $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
468 + }//end foreach
469 + }//end foreach
412 470
413 - // Set the discount array.
414 - $discounts[ $id ]['original_price'] = $item_objet->get_price();
415 - $discounts[ $id ]['cart_item_key'] = $item['key'];
471 + return $discounts;
472 + }
416 473
417 - // Set discount applies to
418 - $discounts[ $id ]['discount_applies_to'][ $r ] = CalcFactory::discount_applies_to( $rule, $campaign );
474 + /**
475 + * Prepare discounts for the `combined` / `variations` counting modes.
476 + *
477 + * Eligibility is resolved by {@see QuantityCounter}: quantities are pooled
478 + * per group (all applicable lines for `combined`, per parent product for
479 + * `variations`), range-capped, and threaded into the Calc layer through the
480 + * `disco_forced_qty` item key.
481 + *
482 + * How many units are actually discounted depends on the rule:
483 + * - Bundle / Bulk (no `get_quantity`) → every qualifying unit is discounted.
484 + * - BOGO (`get_quantity` set) → only the reward units are discounted,
485 + * i.e. `get_quantity × bundles`, where `bundles = floor(pool / min)`
486 + * (1 for a non-recursive rule). Otherwise a "buy 2 get 1" in combined mode
487 + * would wrongly discount the 2 bought units instead of the 1 reward unit.
488 + * Reward units are handed out across the eligible lines in cart order.
489 + *
490 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
491 + * @param \WC_Cart $cart Cart Object.
492 + * @param array $rules Discount rules.
493 + * @param int $total_applicable_qty Combined qualifying quantity.
494 + */
495 + private function get_grouped_item_discounts( Config $campaign, \WC_Cart $cart, array $rules, int $total_applicable_qty ): array {//phpcs:ignore
496 + $discounts = array();
497 + $quantity_counter = new QuantityCounter;
419 498
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 );
499 + foreach ( $rules as $rule ) {
500 + if ( is_object( $rule ) ) {
501 + $rule = (array) $rule;
502 + }
431 503
504 + $eligible_lines = $quantity_counter->get_eligible_units( $cart, $campaign, $rule );
505 +
506 + if ( empty( $eligible_lines ) ) {
507 + continue;
508 + }
509 +
510 + // Total qualifying units across the pooled lines.
511 + $total_qualifying_units = 0;
512 +
513 + foreach ( $eligible_lines as $eligible_line ) {
514 + $total_qualifying_units += $eligible_line['eligible_qty'];
515 + }
516 +
517 + $discountable_units = $this->grouped_discount_budget( $rule, $total_qualifying_units );
518 +
519 + /**
520 + * A flat amount is charged for the pooled group as a whole.
521 + *
522 + * Pooling exists so the cart together can reach the tier minimum, so
523 + * the rule qualifies for the group rather than for each line that
524 + * contributed quantity. The Calc layer returns the rule value whole
525 + * for every line it is handed, because a flat amount does not scale
526 + * with units, so spreading it across lines multiplied the discount by
527 + * the line count: a flat 10 came out as 20 over two lines.
528 + *
529 + * It is applied to the first eligible line carrying the entire pooled
530 + * budget, which is what lets a recursive rule still charge once per
531 + * bundle: the Calc layer derives the bundle count from that quantity.
532 + * Per unit types keep every line, since their amount is meant to
533 + * scale with the units.
534 + */
535 + if ( in_array( $rule['discount_type'], array( 'fixed', 'fixed_price' ), true ) ) {
536 + $first_line = reset( $eligible_lines );
537 +
538 + if ( ! empty( $first_line['item'] ) && $discountable_units > 0 ) {
539 + $item = $first_line['item'];
540 + $item['disco_forced_qty'] = $discountable_units;
541 +
542 + $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
543 + }
544 +
545 + continue;
546 + }
547 +
548 + // Hand out the discountable units across the eligible lines in cart order.
549 + foreach ( $eligible_lines as $eligible_line ) {
550 + if ( $discountable_units <= 0 ) {
551 + break;
552 + }
553 +
554 + $line_units = (int) min( $eligible_line['eligible_qty'], $discountable_units );
555 +
556 + if ( $line_units <= 0 ) {
557 + continue;
558 + }
559 +
560 + $discountable_units -= $line_units;
561 +
562 + $item = $eligible_line['item'];
563 + $item['disco_forced_qty'] = $line_units;
564 +
565 + $this->apply_rule_to_discount( $discounts, $campaign, $rule, $item, $cart, $total_applicable_qty );
566 + }
567 + }
568 +
569 + return $discounts;
570 + }
571 +
572 + /**
573 + * Number of units a pooled rule should actually discount.
574 + *
575 + * BOGO rules (with `get_quantity`) reward `get_quantity × bundles`; every
576 + * other rule discounts the whole qualifying pool.
577 + *
578 + * @param array $rule Discount rule.
579 + * @param int $total_qualifying Pooled qualifying quantity.
580 + */
581 + private function grouped_discount_budget( array $rule, int $total_qualifying ): int {
582 + $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
583 +
584 + if ( $reward_quantity <= 0 ) {
585 + // Bundle / Bulk: discount every qualifying unit.
586 + return $total_qualifying;
587 + }
588 +
589 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
590 + $bundle_count = $minimum_quantity > 0 ? (int) floor( $total_qualifying / $minimum_quantity ) : 1; // phpcs:ignore
591 +
592 + if ( $bundle_count < 1 ) {
593 + $bundle_count = 1;
594 + }
595 +
596 + // BOGO reward: get_quantity per qualifying bundle, never more than the pool.
597 + return (int) min( $reward_quantity * $bundle_count, $total_qualifying );
598 + }
599 +
600 + /**
601 + * Populate the discount array for a single cart line + rule pair.
602 + *
603 + * Shared by both the per-line (`separate`) path and the grouped
604 + * (`combined` / `variations`) path so the emitted discount structure is
605 + * identical regardless of counting mode.
606 + *
607 + * @param array $discounts Discount array, by reference.
608 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
609 + * @param array $rule Single discount rule.
610 + * @param array $item Cart item (may carry `disco_forced_qty`).
611 + * @param \WC_Cart $cart Cart Object.
612 + * @param int $total_applicable_qty Combined qualifying quantity.
613 + */
614 + private function apply_rule_to_discount( array &$discounts, Config $campaign, array $rule, array $item, \WC_Cart $cart, int $total_applicable_qty ): void {//phpcs:ignore
615 + $product = $item['data'];
616 + $rule_key = md5( microtime() . wp_rand() );
617 + $effective_product_id = $this->get_cart_item_id( $item );
618 +
619 + // Set the discount array.
620 + $discounts[ $effective_product_id ]['original_price'] = $product->get_price();
621 + $discounts[ $effective_product_id ]['cart_item_key'] = $item['key'];
622 +
623 + // Set discount applies to
624 + $discounts[ $effective_product_id ]['discount_applies_to'][ $rule_key ] = CalcFactory::discount_applies_to( $rule, $campaign );
625 +
626 + // Set Discounts.
627 + if ( $rule['discount_type'] === 'free' ) {
628 + $discounted_amount = array(
629 + 'price' => $product->get_price(),
630 + 'discount' => 0,
631 + 'line_subtotal' => $product->get_price() * $item['quantity'],
632 + 'discounted_quantities' => 0,
633 + );
634 + $discounts[ $effective_product_id ]['free'] = true;
635 + $discounts[ $effective_product_id ]['get_ids'] = $rule['get_ids'];
636 +
637 + if ( 'products' === $campaign->get_bogo_type() ) {
638 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
432 639 /**
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.
640 + * Combined / variations: the buy-X quantity is pooled across
641 + * the cart, so the reward is one bundle (non-recursive) or one
642 + * per bundle (recursive) of the pooled total — not per line.
436 643 */
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'];
644 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
645 + $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
646 +
647 + if ( 'yes' === $rule['recursive'] && $minimum_quantity > 0 ) {
648 + $discounts[ $effective_product_id ]['get_qty'] = (int) floor( $total_applicable_qty / $minimum_quantity ) * $reward_quantity;
649 + } else {
650 + $discounts[ $effective_product_id ]['get_qty'] = $reward_quantity;
439 651 }
440 652 } 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;
653 + /**
654 + * Separate: X = all products. Every cart product grants the
655 + * reward of each tier its own quantity matches (summed), so
656 + * two products matching different tiers each contribute.
657 + */
658 + $discounts[ $effective_product_id ]['get_qty'] = $this->get_products_bogo_free_quantity( $campaign, $cart );
445 659 }
660 + } else {
661 + /**
662 + * BuyXGetX ('all'): per line, recursive → floor( line qty / min ) ×
663 + * get_quantity. Category BOGO never reaches this — its free items are
664 + * owned by {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}.
665 + */
666 + $discounts[ $effective_product_id ]['get_qty'] = CalcFactory::get_free_quantity( $rule, $item );
667 + }
668 + } else {
669 + $discounted_amount = CalcFactory::get_discount( $rule, $item, $cart, $campaign );
670 + $discounts[ $effective_product_id ]['free'] = false;
671 + $discounts[ $effective_product_id ]['get_ids'] = array();
672 + $discounts[ $effective_product_id ]['get_qty'] = 0;
673 + }
446 674
447 - // $discounts[ $id ]['get_ids'] = array_values( $rule['get_ids'] );
675 + $discounts[ $effective_product_id ]['discount_types'][ $rule_key ] = $rule['discount_type'];
676 + $discounts[ $effective_product_id ]['intent'][ $rule_key ] = $campaign->get_discount_intent();
677 + $discounts[ $effective_product_id ]['prices'][ $rule_key ] = $discounted_amount['price'];
678 + $discounts[ $effective_product_id ]['discounts'][ $rule_key ] = $discounted_amount['discount'];
679 + $discounts[ $effective_product_id ]['subtotals'][ $rule_key ] = $discounted_amount['line_subtotal'];
680 + $discounts[ $effective_product_id ]['discounted_quantities'][ $rule_key ] = $discounted_amount['discounted_quantities'];
681 + $discounts[ $effective_product_id ]['quantities'][ $rule_key ] = $item['quantity'];
448 682
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;
683 + $discounts[ $effective_product_id ]['offers'][ $rule_key ] = $this->get_item_offers( $rule );
463 684 }
464 685
465 686 /**
466 687 * Prepare discounts for cart items.
@@ -473,10 +694,9 @@
473 694 if ( empty( $intents ) ) {
474 695 return false;
475 696 }
476 697
477 - $discounts = array();
478 - $applied_campaign_id = 0;
698 + $discounts = array();
479 699
480 700 // Loop through the intents.
481 701 foreach ( $intents as $intent ) {
482 702 /**
@@ -483,13 +703,9 @@
483 703 * Get a discount limit form campaign.
484 704 *
485 705 * Compare with total product meta and apply discount
486 706 */
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 ) ) {
707 + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
492 708 continue;
493 709 }
494 710
495 711 $items = $this->get_items_for_discount( $cart, $intent->campaign );
@@ -499,9 +715,18 @@
499 715 continue;
500 716 }
501 717
502 718 foreach ( $get_discounts as $item_id => $discount ) {
719 + /**
720 + * Keep the campaign alongside its amount at the same index.
721 + *
722 + * Staging cannot happen yet: several campaigns can offer an amount
723 + * for the same item and only one of them survives the min/max
724 + * reduction below. Crediting every campaign here would consume the
725 + * usage limit of campaigns whose discount never reached the cart.
726 + */
503 727 $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] );
728 + $discounts[ $item_id ]['campaigns'][] = (int) $intent->campaign->id;
504 729 }
505 730 }
506 731
507 732 // Check if the discounts are empty.
@@ -508,15 +733,26 @@
508 733 if ( empty( $discounts ) ) {
509 734 return false;
510 735 }
511 736
737 + $applied_campaign_ids = array();
738 +
512 739 // Get the min or max discount amount for each item.
513 740 foreach ( $discounts as $item_id => $discount ) {
514 - $discounts[ $item_id ] = $this->min_max_average( $discount['discounts'] );
741 + $winning_amount = $this->min_max_average( $discount['discounts'] );
742 + $winning_index = array_search( $winning_amount, $discount['discounts'], true );
743 +
744 + if ( false !== $winning_index && isset( $discount['campaigns'][ $winning_index ] ) ) {
745 + $applied_campaign_ids[ $discount['campaigns'][ $winning_index ] ] = true;
746 + }
747 +
748 + $discounts[ $item_id ] = $winning_amount;
515 749 }
516 750
517 - // Set applied campaign on DiscountLimit class.
518 - ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
751 + // Stage only the campaigns that actually won an item.
752 + foreach ( array_keys( $applied_campaign_ids ) as $applied_campaign_id ) {
753 + ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
754 + }
519 755
520 756 return $discounts;
521 757 }
522 758
@@ -525,11 +761,11 @@
525 761 *
526 762 * @param array $intents Intents.
527 763 * @param \WC_Cart $cart Cart.
528 764 * @param array $discounts Discounts.
529 - * @return int|null
765 + * @return int|string|null
530 766 */
531 - public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {
767 + public function get_valid_bogo_category_item_id( array $intents, \WC_Cart $cart, array $discounts ) {//phpcs:ignore
532 768 foreach ( $intents as $intent ) {
533 769 if (
534 770 $intent->campaign->get_discount_intent() !== 'BuyXGetY' ||
535 771 $intent->campaign->get_bogo_type() !== 'categories'
@@ -536,15 +772,25 @@
536 772 ) {
537 773 continue;
538 774 }
539 775
776 + // Collect every eligible category item (in cart order) with its price,
777 + // then pick the reward item per the campaign's selection strategy.
778 + $candidates = array();
779 +
540 780 foreach ( $cart->get_cart() as $item ) {
541 - $item_id = $this->get_cart_item_id( $item );
781 + $cart_item_id = $this->get_cart_item_id( $item );
542 782
543 - if ( isset( $discounts[ $item_id ] ) && $discounts[ $item_id ] > 0 ) {
544 - return $item_id;
783 + if ( isset( $discounts[ $cart_item_id ] ) && $discounts[ $cart_item_id ] > 0 ) { // phpcs:ignore
784 + $candidates[ $cart_item_id ] = CalcFactory::get_price( $item );
545 785 }
546 786 }
787 +
788 + if ( empty( $candidates ) ) {
789 + continue;
790 + }
791 +
792 + return $this->pick_reward_item( $candidates, $intent->campaign->get_free_item_selection() );
547 793 }
548 794
549 795 return null;
550 796 }
@@ -549,8 +795,34 @@
549 795 return null;
550 796 }
551 797
552 798 /**
799 + * Pick the reward cart-item id from eligible candidates.
800 + *
801 + * Honours the "Free Item Selection" strategy when more than one product
802 + * from the reward category is in the cart:
803 + * - `cart_order` (default) → the first eligible item in cart order.
804 + * - `lowest` → the lowest-priced eligible item.
805 + * - `highest` → the highest-priced eligible item.
806 + * Ties keep cart order (first match wins).
807 + *
808 + * @param array<int|string, float> $candidates Map of item id => price, in cart order.
809 + * @param string $selection Selection strategy.
810 + * @return int|string|null
811 + */
812 + private function pick_reward_item( array $candidates, string $selection ) {
813 + if ( 'lowest' === $selection ) {
814 + return array_keys( $candidates, min( $candidates ), true )[0];
815 + }
816 +
817 + if ( 'highest' === $selection ) {
818 + return array_keys( $candidates, max( $candidates ), true )[0];
819 + }
820 +
821 + return array_key_first( $candidates );
822 + }
823 +
824 + /**
553 825 * Prepare discounts for cart items.
554 826 *
555 827 * @param array $intents Intents.
556 828 * @param \WC_Cart $cart Cart.
@@ -560,12 +832,11 @@
560 832 if ( empty( $intents ) ) {
561 833 return false;
562 834 }
563 835
564 - $discounts = array();
836 + $discounts = array();
837 + $candidate_campaign_ids = array();
565 838
566 - $applied_campaign_id = 0;
567 -
568 839 // Loop through the intents.
569 840 foreach ( $intents as $intent ) {
570 841 /**
571 842 * Get a discount limit form campaign.
@@ -571,13 +842,9 @@
571 842 * Get a discount limit form campaign.
572 843 *
573 844 * Compare with total product meta and apply discount
574 845 */
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 ) ) {
846 + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) {
580 847 continue;
581 848 }
582 849
583 850 $items = $this->get_items_for_discount( $cart, $intent->campaign );
@@ -586,14 +853,26 @@
586 853 if ( empty( $get_discounts ) ) {
587 854 continue;
588 855 }
589 856
857 + /**
858 + * Collect the campaign now, stage it later.
859 + *
860 + * What this method finally grants is decided well below: the rewards
861 + * are unioned across campaigns and a BuyXGetY (products) campaign can
862 + * then override the whole result authoritatively. Crediting a campaign
863 + * here would consume its usage limit even when the final result grants
864 + * it nothing.
865 + */
866 + $candidate_campaign_ids[] = (int) $intent->campaign->id;
867 +
590 868 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();
869 + $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); // phpcs:ignore
870 + $discounts[ $item_id ]['free'] = $discount['free']; // phpcs:ignore
871 + $discounts[ $item_id ]['get_ids'] = $discount['get_ids']; // phpcs:ignore
872 + $discounts[ $item_id ]['get_qty'] = $discount['get_qty']; // phpcs:ignore
873 + $discounts[ $item_id ]['bogo_type'] = $intent->campaign->get_bogo_type(); // phpcs:ignore
874 + $discounts[ $item_id ]['free_item_selection'] = $intent->campaign->get_free_item_selection(); // phpcs:ignore
596 875 }
597 876 }
598 877
599 878 // Check if the discounts are empty.
@@ -600,29 +879,309 @@
600 879 if ( empty( $discounts ) ) {
601 880 return false;
602 881 }
603 882
604 - // Get the min or max discount amount for each item.
883 + /**
884 + * Aggregate the per-item free rewards into the flat shape the cart hook
885 + * consumes. Union every qualifying reward id (previously this overwrote
886 + * on each pass, so only the last cart item was ever granted its free
887 + * product) and keep a per-id quantity map for products whose free counts
888 + * differ (e.g. recursive BuyXGetX).
889 + */
890 + $reward_product_ids = array();
891 + $reward_quantity_map = array();
892 + $has_free_items = false;
893 + $bogo_type = 'products';
894 + $free_item_selection = 'cart_order';
895 +
605 896 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';
897 + $has_free_items = $discount['free'];
898 + $bogo_type = $discount['bogo_type'] ?? 'products';
899 + $free_item_selection = $discount['free_item_selection'] ?? 'cart_order';
900 +
901 + $rule_reward_ids = ! empty( $discount['get_ids'] ) ? array_column( $discount['get_ids'], 'id' ) : array( $item_id ); // phpcs:ignore
902 +
903 + foreach ( $rule_reward_ids as $reward_product_id ) {
904 + $reward_product_id = (int) $reward_product_id; // phpcs:ignore
905 + $reward_product_ids[] = $reward_product_id; // phpcs:ignore
906 + $reward_quantity_map[ $reward_product_id ] = (int) $discount['get_qty']; // phpcs:ignore
907 + }
611 908 }
612 909
613 - // Set applied campaign on DiscountLimit class.
614 - ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
910 + $reward_product_ids = array_values( array_unique( $reward_product_ids ) );
615 911
912 + $discounts['discount'] = 0.0;
913 + $discounts['free'] = $has_free_items;
914 + $discounts['get_ids'] = $reward_product_ids;
915 + $discounts['get_qty'] = empty( $reward_quantity_map ) ? 0 : max( $reward_quantity_map ); // phpcs:ignore
916 + $discounts['get_qty_map'] = $reward_quantity_map;
917 + $discounts['bogo_type'] = $bogo_type;
918 + $discounts['free_item_selection'] = $free_item_selection;
919 +
920 + /**
921 + * BuyXGetY (products): replace the flat aggregation with an authoritative
922 + * reward map keyed by the tier's own get product, so each cart product
923 + * that qualifies grants its tier's reward.
924 + *
925 + * Category BOGO is not handled here — {@see \Disco\App\Intents\CategoryBogo\CategoryBogo}
926 + * owns its reward selection, buy reservation and cart reconciliation.
927 + */
928 + foreach ( $intents as $intent ) {
929 + $bogo_type = $intent->campaign->get_bogo_type();
930 +
931 + if ( 'BuyXGetY' !== $intent->campaign->get_discount_intent() || 'products' !== $bogo_type ) {
932 + continue;
933 + }
934 +
935 + // Only free-type rules grant free items; a percent/fixed BOGO must not
936 + // fabricate free products here.
937 + $rules = $intent->campaign->get_discount_rules();
938 + $first_rule = ( is_array( $rules ) && isset( $rules[0] ) ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
939 +
940 + if ( ! isset( $first_rule['discount_type'] ) || 'free' !== $first_rule['discount_type'] ) {
941 + continue;
942 + }
943 +
944 + $reward_map = $this->compute_bogo_products_free_map( $intent->campaign, $cart );
945 +
946 + // Always authoritative: an empty map means no qualifying (non-reward)
947 + // purchase, so no free items — do not fall back to the flat count.
948 + $discounts['get_ids'] = array_keys( $reward_map ); // phpcs:ignore
949 + $discounts['get_qty_map'] = $reward_map; // phpcs:ignore
950 + // Each get-product is freed its own quantity.
951 + $discounts['get_qty'] = empty( $reward_map ) ? 0 : max( $reward_map ); // phpcs:ignore
952 + $discounts['free'] = ! empty( $reward_map ); // phpcs:ignore
953 + $discounts['bogo_type'] = $bogo_type;
954 + $discounts['free_item_selection'] = $intent->campaign->get_free_item_selection();
955 +
956 + /**
957 + * This branch is authoritative for the whole result, so the campaign
958 + * that owns it is the only one that can be credited.
959 + */
960 + $candidate_campaign_ids = array( (int) $intent->campaign->id );
961 + }
962 +
963 + /**
964 + * Stage the campaigns only once the result is final, and only when it
965 + * actually grants free items.
966 + */
967 + if ( ! empty( $discounts['free'] ) ) {
968 + foreach ( array_unique( $candidate_campaign_ids ) as $applied_campaign_id ) {
969 + ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id );
970 + }
971 + }
972 +
616 973 return $discounts;
617 974 }
618 975
619 976 /**
977 + * Authoritative free Y map for a BuyXGetY (products) campaign.
978 + *
979 + * Returns get-product id => free quantity, attributing each tier's reward to
980 + * that tier's own get-products:
981 + * - `separate` → each cart line credits every tier whose range
982 + * its own quantity matches ([min,max]); rewards
983 + * sum, and each tier credits its own get-products.
984 + * - `combined` → one pool over every applicable line; the highest
985 + * tier it reaches credits its get-products once.
986 + * - `variations` → one pool per parent product (all variations of
987 + * the same variable count together, and a simple
988 + * product is its own pool); every pool that
989 + * reaches a tier credits that tier's get-products,
990 + * so two qualifying parents grant two rewards.
991 + * Recursive tiers multiply by floor(qty/min) (line qty for separate, pool qty
992 + * for combined / variations).
993 + *
994 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
995 + * @param \WC_Cart $cart Cart Object.
996 + * @return array<int, int>
997 + */
998 + private function compute_bogo_products_free_map( Config $campaign, \WC_Cart $cart ): array {//phpcs:ignore
999 + $rules = $campaign->get_discount_rules();
1000 +
1001 + if ( ! is_array( $rules ) || empty( $rules ) ) {
1002 + return array();
1003 + }
1004 +
1005 + $reward_map = array();
1006 +
1007 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
1008 + foreach ( $this->get_bogo_buy_pool_quantities( $cart, $campaign ) as $pool_quantity ) {
1009 + $this->credit_bogo_pool_reward( $reward_map, $rules, $pool_quantity );
1010 + }
1011 +
1012 + return $reward_map;
1013 + }
1014 +
1015 + /**
1016 + * Separate: build the buy quantity per product, then each product earns
1017 + * the highest tier its quantity qualifies for (min <= qty). The upper
1018 + * `max` marks where the next tier begins, not a cut-off, so a quantity
1019 + * above the top tier still earns it and a gap earns the tier below.
1020 + * Free lines never count.
1021 + */
1022 + $buy_quantity_per_product = array();
1023 +
1024 + foreach ( $cart->get_cart() as $item ) {
1025 + if ( ! empty( $item['is_free_product'] ) ) {
1026 + continue;
1027 + }
1028 +
1029 + $effective_product_id = $item['product_id'];
1030 +
1031 + if ( ! empty( $item['variation_id'] ) ) {
1032 + $effective_product_id = $item['variation_id'];
1033 + }
1034 +
1035 + if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1036 + continue;
1037 + }
1038 +
1039 + $product = wc_get_product( $effective_product_id );
1040 +
1041 + if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1042 + continue;
1043 + }
1044 +
1045 + $buy_quantity_per_product[ (int) $effective_product_id ] = ( $buy_quantity_per_product[ (int) $effective_product_id ] ?? 0 ) + (int) $item['quantity'];
1046 + }
1047 +
1048 + foreach ( $buy_quantity_per_product as $quantity ) {
1049 + $qualifying_rule = null;
1050 + $qualifying_minimum = -1;
1051 +
1052 + foreach ( $rules as $rule ) {
1053 + $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1054 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1055 + $reward_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1056 +
1057 + if ( $minimum_quantity <= 0 || $reward_quantity <= 0 || empty( $rule['get_ids'] ) ) {
1058 + continue;
1059 + }
1060 +
1061 + if ( $quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1062 + $qualifying_rule = $rule;
1063 + $qualifying_minimum = $minimum_quantity;
1064 + }
1065 + }
1066 +
1067 + if ( null === $qualifying_rule ) {
1068 + continue;
1069 + }
1070 +
1071 + $minimum_quantity = absint( $qualifying_rule['min'] );
1072 + $reward_quantity = (int) $qualifying_rule['get_quantity'];
1073 + $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1074 + $reward_total = ( $is_recursive && $minimum_quantity > 0 ) ? ( (int) floor( $quantity / $minimum_quantity ) * $reward_quantity ) : $reward_quantity; // phpcs:ignore
1075 +
1076 + if ( $reward_total > 0 ) { // phpcs:ignore
1077 + foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1078 + $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $reward_total;
1079 + }
1080 + }
1081 + }
1082 +
1083 + return $reward_map;
1084 + }
1085 +
1086 + /**
1087 + * Pooled buy quantities for combined / variations, excluding free lines.
1088 + *
1089 + * `combined` returns a single pool over every applicable line. `variations`
1090 + * returns one pool per parent product, so variations of the same variable
1091 + * product count together while different parents stay apart — the same
1092 + * grouping {@see QuantityCounter} applies to the buy-X gate.
1093 + *
1094 + * @param \WC_Cart $cart Cart Object.
1095 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1096 + * @return array<string, int> Pool key => pooled quantity.
1097 + */
1098 + private function get_bogo_buy_pool_quantities( \WC_Cart $cart, Config $campaign ): array {//phpcs:ignore
1099 + $pool_quantities = array();
1100 + $pool_per_parent = 'variations' === $campaign->get_count_quantity_as();
1101 + $cart_items = $cart->get_cart();
1102 +
1103 + if ( ! is_array( $cart_items ) ) {
1104 + return $pool_quantities;
1105 + }
1106 +
1107 + foreach ( $cart_items as $item ) {
1108 + if ( ! empty( $item['is_free_product'] ) ) {
1109 + continue;
1110 + }
1111 +
1112 + $effective_product_id = $item['product_id'];
1113 +
1114 + if ( ! empty( $item['variation_id'] ) ) {
1115 + $effective_product_id = $item['variation_id'];
1116 + }
1117 +
1118 + if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1119 + continue;
1120 + }
1121 +
1122 + $product = wc_get_product( $effective_product_id );
1123 +
1124 + if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1125 + continue;
1126 + }
1127 +
1128 + $pool_key = $pool_per_parent ? 'parent_' . (int) $item['product_id'] : 'all'; // phpcs:ignore
1129 +
1130 + $pool_quantities[ $pool_key ] = ( $pool_quantities[ $pool_key ] ?? 0 ) + (int) $item['quantity'];
1131 + }
1132 +
1133 + return $pool_quantities;
1134 + }
1135 +
1136 + /**
1137 + * Credit one pool's reward to the get-products of the tier it qualifies for.
1138 + *
1139 + * The winning tier is the one with the highest `min` the pool reaches; a
1140 + * recursive tier multiplies its reward by the number of complete sets.
1141 + *
1142 + * @param array $reward_map Reward map (get-product id => quantity), by reference.
1143 + * @param array $rules Discount rules.
1144 + * @param int $pool_quantity Pooled buy quantity.
1145 + */
1146 + private function credit_bogo_pool_reward( array &$reward_map, array $rules, int $pool_quantity ): void {//phpcs:ignore
1147 + $qualifying_rule = null;
1148 + $qualifying_minimum = -1;
1149 +
1150 + foreach ( $rules as $rule ) {
1151 + $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1152 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1153 +
1154 + if ( $minimum_quantity > 0 && $pool_quantity >= $minimum_quantity && $minimum_quantity > $qualifying_minimum ) { // phpcs:ignore
1155 + $qualifying_rule = $rule;
1156 + $qualifying_minimum = $minimum_quantity;
1157 + }
1158 + }
1159 +
1160 + if ( null === $qualifying_rule || empty( $qualifying_rule['get_ids'] ) ) {
1161 + return;
1162 + }
1163 +
1164 + $minimum_quantity = absint( $qualifying_rule['min'] );
1165 + $reward_quantity = ! empty( $qualifying_rule['get_quantity'] ) ? (int) $qualifying_rule['get_quantity'] : 0; // phpcs:ignore
1166 + $is_recursive = ! empty( $qualifying_rule['recursive'] ) && 'yes' === $qualifying_rule['recursive']; // phpcs:ignore
1167 + $quantity = ( $is_recursive && $minimum_quantity > 0 ) ? (int) floor( $pool_quantity / $minimum_quantity ) * $reward_quantity : $reward_quantity; // phpcs:ignore
1168 +
1169 + if ( $quantity <= 0 ) {
1170 + return;
1171 + }
1172 +
1173 + foreach ( array_column( $qualifying_rule['get_ids'], 'id' ) as $reward_product_id ) {
1174 + $reward_map[ (int) $reward_product_id ] = ( $reward_map[ (int) $reward_product_id ] ?? 0 ) + $quantity;
1175 + }
1176 + }
1177 +
1178 + /**
620 1179 * Is it a bogo campaign?
621 1180 *
622 1181 * @param \Disco\App\Utility\Config $campaign Campaign Config.
623 1182 */
624 - public function is_bogo( Config $campaign ): bool {
1183 + public function is_bogo( Config $campaign ): bool { // phpcs:ignore
625 1184 return in_array( $campaign->get_discount_intent(), array( 'BuyXGetX', 'BuyXGetY' ), true );
626 1185 }
627 1186
628 1187 /**
@@ -651,8 +1210,111 @@
651 1210 return min( $discounts );
652 1211 }
653 1212
654 1213 /**
1214 + * Strip the embedded tax portion from a discount amount.
1215 + *
1216 + * When WooCommerce is set to enter prices inclusive of tax, the discount
1217 + * amount computed by the intents also carries that tax (e.g. a 50 discount
1218 + * becomes 55 at 10% tax). The cart fee is added as non-taxable, so the tax
1219 + * must be removed here to keep the net discount correct.
1220 + *
1221 + * When a cart is supplied, the embedded-tax portion is derived from the
1222 + * cart's own subtotal and subtotal tax rather than assuming the whole
1223 + * amount carries the standard rate. This keeps mixed carts correct where
1224 + * some products are taxable and others are not (or use different tax
1225 + * classes): non-taxable products add to the subtotal but contribute zero
1226 + * tax, so the effective inclusive-tax ratio drops accordingly.
1227 + *
1228 + * @since 1.3.54
1229 + * @param float $amount Tax-inclusive discount amount.
1230 + * @param \WC_Cart|null $cart Optional cart used to derive the effective tax ratio.
1231 + * @return float Tax-exclusive discount amount.
1232 + */
1233 + public function get_discount_exclude_tax( float $amount, $cart = null ): float {
1234 + if ( ! function_exists( 'wc_prices_include_tax' ) || ! wc_prices_include_tax() ) {
1235 + return $amount;
1236 + }
1237 +
1238 + if ( ! class_exists( 'WC_Tax' ) ) {
1239 + return $amount;
1240 + }
1241 +
1242 + // Prefer a product-derived inclusive-tax ratio so mixed carts strip
1243 + // only the tax actually embedded. The discount base is built from each
1244 + // product's tax-inclusive price (see CalcFactory::get_price), so we
1245 + // rebuild the same base excluding tax per product. wc_get_price_excluding_tax
1246 + // respects each product's own tax status and class: a non-taxable
1247 + // product returns its price unchanged and contributes zero embedded tax.
1248 + if ( $cart instanceof \WC_Cart && function_exists( 'wc_get_price_excluding_tax' ) ) {
1249 + return $this->strip_tax_using_cart_ratio( $amount, $cart );
1250 + }
1251 +
1252 + // Fallback: rates for the standard tax class at the customer location.
1253 + $rates = \WC_Tax::get_rates( '' );
1254 +
1255 + if ( empty( $rates ) ) {
1256 + return $amount;
1257 + }
1258 +
1259 + // Tax embedded inside the inclusive amount, then remove it.
1260 + $tax_total = array_sum( \WC_Tax::calc_inclusive_tax( $amount, $rates ) );
1261 +
1262 + return $amount - $tax_total;
1263 + }
1264 +
1265 + /**
1266 + * Strip embedded tax from a discount using the cart's own inclusive-tax ratio.
1267 + *
1268 + * Rebuilds the discount base (sum of tax-inclusive prices) and its
1269 + * tax-exclusive counterpart per product, then scales the discount by the
1270 + * excl/incl ratio. Non-taxable products contribute equally to both sums,
1271 + * so their share keeps no tax stripped.
1272 + *
1273 + * @param float $amount Tax-inclusive discount amount.
1274 + * @param \WC_Cart $cart Cart used to derive the ratio.
1275 + * @return float Tax-exclusive discount amount.
1276 + */
1277 + private function strip_tax_using_cart_ratio( float $amount, \WC_Cart $cart ): float {
1278 + $incl_sum = 0.0;
1279 + $excl_sum = 0.0;
1280 + $cart_items = $cart->get_cart();
1281 +
1282 + if ( ! is_array( $cart_items ) ) {
1283 + $cart_items = array();
1284 + }
1285 +
1286 + foreach ( $cart_items as $cart_item ) {
1287 + if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof \WC_Product ) {
1288 + continue;
1289 + }
1290 +
1291 + $product = $cart_item['data'];
1292 + $qty = (float) ( $cart_item['quantity'] ?? 1 );
1293 + $price_incl = (float) $product->get_price();
1294 + $price_excl = (float) wc_get_price_excluding_tax( $product, array( 'price' => $price_incl ) );
1295 +
1296 + $incl_sum += $price_incl * $qty;
1297 + $excl_sum += $price_excl * $qty;
1298 + }
1299 +
1300 + // No priced items in the cart -> nothing to strip.
1301 + if ( $incl_sum <= 0 ) {
1302 + return $amount;
1303 + }
1304 +
1305 + $net = $amount * $excl_sum / $incl_sum;
1306 +
1307 + $decimals = 2;
1308 +
1309 + if ( function_exists( 'wc_get_price_decimals' ) ) {
1310 + $decimals = wc_get_price_decimals();
1311 + }
1312 +
1313 + return round( $net, $decimals );
1314 + }
1315 +
1316 + /**
655 1317 * Total qualifying quantity for recursive BOGO.
656 1318 *
657 1319 * Sums the quantity of every cart item that passes the campaign's
658 1320 * few-products / "all" filter (product_is_applicable) and its conditions
@@ -699,8 +1361,97 @@
699 1361 return $total;
700 1362 }
701 1363
702 1364 /**
1365 + * Whether a campaign is a free products BOGO evaluated per product.
1366 + *
1367 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1368 + * @param array $rules Discount rules.
1369 + */
1370 + private function is_per_product_free_bogo( Config $campaign, array $rules ): bool {
1371 + if ( 'products' !== $campaign->get_bogo_type() ) {
1372 + return false;
1373 + }
1374 +
1375 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) {
1376 + return false;
1377 + }
1378 +
1379 + $first_rule = isset( $rules[0] ) ? ( is_object( $rules[0] ) ? (array) $rules[0] : $rules[0] ) : array(); // phpcs:ignore
1380 +
1381 + return isset( $first_rule['discount_type'] ) && 'free' === $first_rule['discount_type'];
1382 + }
1383 +
1384 + /**
1385 + * Total free Y quantity for a products BuyXGetY campaign in `separate` mode.
1386 + *
1387 + * X is every applicable product. Each cart line grants the reward of every
1388 + * rule (tier) whose range its own quantity falls in — `qty` within
1389 + * `[min, max]` (no max = min-only) — summed across matching tiers, and ×
1390 + * floor(qty/min) when the tier is recursive. The Y product counts as an X
1391 + * line too. Summing across all qualifying lines gives the free Y count, so
1392 + * two products each matching a different tier grant each tier's reward.
1393 + *
1394 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1395 + * @param \WC_Cart $cart Cart Object.
1396 + */
1397 + private function get_products_bogo_free_quantity( Config $campaign, \WC_Cart $cart ): int {//phpcs:ignore
1398 + $rules = $campaign->get_discount_rules();
1399 + $cart_items = $cart->get_cart();
1400 +
1401 + if ( ! is_array( $rules ) || ! is_array( $cart_items ) ) {
1402 + return 0;
1403 + }
1404 +
1405 + $free_quantity_total = 0;
1406 +
1407 + foreach ( $cart_items as $item ) {
1408 + if ( ! empty( $item['is_free_product'] ) ) {
1409 + continue;
1410 + }
1411 +
1412 + $effective_product_id = $item['product_id'];
1413 +
1414 + if ( ! empty( $item['variation_id'] ) ) {
1415 + $effective_product_id = $item['variation_id'];
1416 + }
1417 +
1418 + if ( ! $campaign->product_is_applicable( $effective_product_id ) ) {
1419 + continue;
1420 + }
1421 +
1422 + $product = wc_get_product( $effective_product_id );
1423 +
1424 + if ( ! Helper::is_filter_passed( $campaign, array( 'product' => $product ) ) ) {
1425 + continue;
1426 + }
1427 +
1428 + $line_quantity = (int) $item['quantity'];
1429 +
1430 + foreach ( $rules as $rule ) {
1431 + $rule = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1432 + $minimum_quantity = isset( $rule['min'] ) ? absint( $rule['min'] ) : 0; // phpcs:ignore
1433 + $maximum_quantity = ! empty( $rule['max'] ) ? absint( $rule['max'] ) : 0; // phpcs:ignore
1434 + $get_quantity = ! empty( $rule['get_quantity'] ) ? (int) $rule['get_quantity'] : 0; // phpcs:ignore
1435 + $recursive = ! empty( $rule['recursive'] ) && 'yes' === $rule['recursive']; // phpcs:ignore
1436 +
1437 + if ( $minimum_quantity <= 0 || $get_quantity <= 0 || $line_quantity < $minimum_quantity ) {
1438 + continue;
1439 + }
1440 +
1441 + // Respect the tier's upper bound so each product matches its tier.
1442 + if ( $maximum_quantity > 0 && $line_quantity > $maximum_quantity && 'yes' !== $rule['recursive'] ) {
1443 + continue;
1444 + }
1445 +
1446 + $free_quantity_total += $recursive ? ( (int) floor( $line_quantity / $minimum_quantity ) * $get_quantity ) : $get_quantity; // phpcs:ignore
1447 + }
1448 + }
1449 +
1450 + return $free_quantity_total;
1451 + }
1452 +
1453 + /**
703 1454 * Get Offer Label.
704 1455 *
705 1456 * @param array $rule Discount Rule.
706 1457 */
@@ -850,9 +1601,56 @@
850 1601 *
851 1602 * @param \Disco\App\Utility\Config $campaign Campaign Config.
852 1603 * @param array $rule Discount Rules.
853 1604 */
1605 + /**
1606 + * Pick the single BuyXGetY tier that applies for the current cart.
1607 + *
1608 + * Tiers are ranked by `min`: the winner is the qualifying rule with the
1609 + * highest `min` that the buy quantity still meets (the broken upper `max` is
1610 + * ignored — a rule qualifies on `qty >= min`, mirroring the gate). On a tie,
1611 + * the later rule wins. Returns a list with 0 or 1 rule.
1612 + *
1613 + * @param \Disco\App\Utility\Config $campaign Campaign Config.
1614 + * @param array $rules All discount rules.
1615 + */
1616 + private function select_bogo_tier_rule( Config $campaign, array $rules ): array {
1617 + $qualifying_rule = null;
1618 + $qualifying_minimum = -1;
1619 +
1620 + foreach ( $rules as $rule ) {
1621 + $rule_array = is_object( $rule ) ? (array) $rule : $rule; // phpcs:ignore
1622 +
1623 + // Rule only counts if the cart meets its buy-X threshold.
1624 + if ( ! $this->verify_xproduct_cart_rule( $campaign, $rule_array ) ) {
1625 + continue;
1626 + }
1627 +
1628 + $minimum_quantity = isset( $rule_array['min'] ) ? absint( $rule_array['min'] ) : 0; // phpcs:ignore
1629 +
1630 + if ( $minimum_quantity >= $qualifying_minimum ) { // phpcs:ignore
1631 + $qualifying_minimum = $minimum_quantity;
1632 + $qualifying_rule = $rule;
1633 + }
1634 + }
1635 +
1636 + return null === $qualifying_rule ? array() : array( $qualifying_rule ); // phpcs:ignore
1637 + }
1638 +
854 1639 private function verify_xproduct_cart_rule( Config $campaign, array $rule ): bool { //phpcs:ignore
1640 + /**
1641 + * "Count Quantity As" combined / variations: pool the buy-X quantity
1642 + * across cart lines instead of requiring a single line to reach `min`,
1643 + * so a mixed cart (e.g. 1 + 1 of different products) can satisfy a
1644 + * "buy 2" threshold. QuantityCounter honours the same min / max /
1645 + * recursive semantics used elsewhere.
1646 + */
1647 + if ( in_array( $campaign->get_count_quantity_as(), array( 'combined', 'variations' ), true ) ) { // phpcs:ignore
1648 + $quantity_counter = new QuantityCounter;
1649 +
1650 + return ! empty( $quantity_counter->get_eligible_units( WC()->cart, $campaign, $rule ) );
1651 + }
1652 +
855 1653 $cart = WC()->cart->get_cart();
856 1654
857 1655 foreach ( $cart as $item ) {
858 1656 // Skip free products - they must not count towards the BOGO buy quantity,