| @@ -251,17 +251,39 @@ | ||
| 251 | 251 | public function calculate_discount( $cost, $discount_type, $discount_value ) { |
| 252 | 252 | $cost = (float) $cost; |
| 253 | 253 | $discount_value = (float) $discount_value; |
| 254 | 254 | |
| 255 | - if ( 'percent' === $discount_type ) { | |
| 256 | - $cost = $cost * $discount_value / 100; | |
| 257 | - } | |
| 255 | + switch ( $discount_type ) { | |
| 256 | + case 'percent': | |
| 257 | + return $cost * $discount_value / 100; | |
| 258 | 258 | |
| 259 | - if ( 'fixed' === $discount_type || 'fixed_per_product' === $discount_type ) { | |
| 260 | - $cost = $discount_value; | |
| 261 | - } | |
| 259 | + case 'fixed': | |
| 260 | + case 'fixed_per_product': | |
| 261 | + return $discount_value; | |
| 262 | 262 | |
| 263 | - 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 | |
| 264 | 286 | } |
| 265 | 287 | |
| 266 | 288 | /** |
| 267 | 289 | * Get the discounted price. |
| @@ -493,8 +515,37 @@ | ||
| 493 | 515 | } |
| 494 | 516 | |
| 495 | 517 | $discountable_units = $this->grouped_discount_budget( $rule, $total_qualifying_units ); |
| 496 | 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 | + | |
| 497 | 548 | // Hand out the discountable units across the eligible lines in cart order. |
| 498 | 549 | foreach ( $eligible_lines as $eligible_line ) { |
| 499 | 550 | if ( $discountable_units <= 0 ) { |
| 500 | 551 | break; |
| @@ -652,17 +703,10 @@ | ||
| 652 | 703 | * Get a discount limit form campaign. |
| 653 | 704 | * |
| 654 | 705 | * Compare with total product meta and apply discount |
| 655 | 706 | */ |
| 656 | - $discount_limit = $intent->campaign->discount_max_user; | |
| 657 | - | |
| 658 | - // Only count applied orders when a limit is actually configured. | |
| 659 | - if ( ! empty( $discount_limit ) && $discount_limit >= 0 ) { | |
| 660 | - $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id ); | |
| 661 | - | |
| 662 | - if ( $total_applied_campaign >= $discount_limit ) { | |
| 663 | - continue; | |
| 664 | - } | |
| 707 | + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) { | |
| 708 | + continue; | |
| 665 | 709 | } |
| 666 | 710 | |
| 667 | 711 | $items = $this->get_items_for_discount( $cart, $intent->campaign ); |
| 668 | 712 | $get_discounts = $intent->get_discounts( $items, $cart ); |
| @@ -670,13 +714,19 @@ | ||
| 670 | 714 | if ( empty( $get_discounts ) ) { |
| 671 | 715 | continue; |
| 672 | 716 | } |
| 673 | 717 | |
| 674 | - // Only stage campaigns whose discount actually applied to the cart. | |
| 675 | - ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id ); | |
| 676 | - | |
| 677 | 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 | + */ | |
| 678 | 727 | $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); |
| 728 | + $discounts[ $item_id ]['campaigns'][] = (int) $intent->campaign->id; | |
| 679 | 729 | } |
| 680 | 730 | } |
| 681 | 731 | |
| 682 | 732 | // Check if the discounts are empty. |
| @@ -683,13 +733,27 @@ | ||
| 683 | 733 | if ( empty( $discounts ) ) { |
| 684 | 734 | return false; |
| 685 | 735 | } |
| 686 | 736 | |
| 737 | + $applied_campaign_ids = array(); | |
| 738 | + | |
| 687 | 739 | // Get the min or max discount amount for each item. |
| 688 | 740 | foreach ( $discounts as $item_id => $discount ) { |
| 689 | - $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; | |
| 690 | 749 | } |
| 691 | 750 | |
| 751 | + // Stage only the campaigns that actually won an item. | |
| 752 | + foreach ( array_keys( $applied_campaign_ids ) as $applied_campaign_id ) { | |
| 753 | + ( new UserLimit )->disco_start_session_on_checkout( $applied_campaign_id ); | |
| 754 | + } | |
| 755 | + | |
| 692 | 756 | return $discounts; |
| 693 | 757 | } |
| 694 | 758 | |
| 695 | 759 | /** |
| @@ -768,9 +832,10 @@ | ||
| 768 | 832 | if ( empty( $intents ) ) { |
| 769 | 833 | return false; |
| 770 | 834 | } |
| 771 | 835 | |
| 772 | - $discounts = array(); | |
| 836 | + $discounts = array(); | |
| 837 | + $candidate_campaign_ids = array(); | |
| 773 | 838 | |
| 774 | 839 | // Loop through the intents. |
| 775 | 840 | foreach ( $intents as $intent ) { |
| 776 | 841 | /** |
| @@ -777,17 +842,10 @@ | ||
| 777 | 842 | * Get a discount limit form campaign. |
| 778 | 843 | * |
| 779 | 844 | * Compare with total product meta and apply discount |
| 780 | 845 | */ |
| 781 | - $discount_limit = $intent->campaign->discount_max_user; | |
| 782 | - | |
| 783 | - // Only count applied orders when a limit is actually configured. | |
| 784 | - if ( ! empty( $discount_limit ) && $discount_limit >= 0 ) { | |
| 785 | - $total_applied_campaign = ( new UserLimit )->disco_get_total_applied_campaign( $intent->campaign->id ); | |
| 786 | - | |
| 787 | - if ( $total_applied_campaign >= $discount_limit ) { | |
| 788 | - continue; | |
| 789 | - } | |
| 846 | + if ( ( new UserLimit )->disco_is_limit_reached( $intent->campaign ) ) { | |
| 847 | + continue; | |
| 790 | 848 | } |
| 791 | 849 | |
| 792 | 850 | $items = $this->get_items_for_discount( $cart, $intent->campaign ); |
| 793 | 851 | $get_discounts = $intent->get_discounts( $items, $cart ); |
| @@ -795,10 +853,18 @@ | ||
| 795 | 853 | if ( empty( $get_discounts ) ) { |
| 796 | 854 | continue; |
| 797 | 855 | } |
| 798 | 856 | |
| 799 | - // Only stage campaigns whose discount actually applied to the cart. | |
| 800 | - ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id ); | |
| 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; | |
| 801 | 867 | |
| 802 | 868 | foreach ( $get_discounts as $item_id => $discount ) { |
| 803 | 869 | $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); // phpcs:ignore |
| 804 | 870 | $discounts[ $item_id ]['free'] = $discount['free']; // phpcs:ignore |
| @@ -885,8 +951,24 @@ | ||
| 885 | 951 | $discounts['get_qty'] = empty( $reward_map ) ? 0 : max( $reward_map ); // phpcs:ignore |
| 886 | 952 | $discounts['free'] = ! empty( $reward_map ); // phpcs:ignore |
| 887 | 953 | $discounts['bogo_type'] = $bogo_type; |
| 888 | 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 | + } | |
| 889 | 971 | } |
| 890 | 972 | |
| 891 | 973 | return $discounts; |
| 892 | 974 | } |