| @@ -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; |
| @@ -663,13 +714,19 @@ | ||
| 663 | 714 | if ( empty( $get_discounts ) ) { |
| 664 | 715 | continue; |
| 665 | 716 | } |
| 666 | 717 | |
| 667 | - // Only stage campaigns whose discount actually applied to the cart. | |
| 668 | - ( new UserLimit )->disco_start_session_on_checkout( $intent->campaign->id ); | |
| 669 | - | |
| 670 | 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 | + */ | |
| 671 | 727 | $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); |
| 728 | + $discounts[ $item_id ]['campaigns'][] = (int) $intent->campaign->id; | |
| 672 | 729 | } |
| 673 | 730 | } |
| 674 | 731 | |
| 675 | 732 | // Check if the discounts are empty. |
| @@ -676,13 +733,27 @@ | ||
| 676 | 733 | if ( empty( $discounts ) ) { |
| 677 | 734 | return false; |
| 678 | 735 | } |
| 679 | 736 | |
| 737 | + $applied_campaign_ids = array(); | |
| 738 | + | |
| 680 | 739 | // Get the min or max discount amount for each item. |
| 681 | 740 | foreach ( $discounts as $item_id => $discount ) { |
| 682 | - $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; | |
| 683 | 749 | } |
| 684 | 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 | + | |
| 685 | 756 | return $discounts; |
| 686 | 757 | } |
| 687 | 758 | |
| 688 | 759 | /** |
| @@ -761,9 +832,10 @@ | ||
| 761 | 832 | if ( empty( $intents ) ) { |
| 762 | 833 | return false; |
| 763 | 834 | } |
| 764 | 835 | |
| 765 | - $discounts = array(); | |
| 836 | + $discounts = array(); | |
| 837 | + $candidate_campaign_ids = array(); | |
| 766 | 838 | |
| 767 | 839 | // Loop through the intents. |
| 768 | 840 | foreach ( $intents as $intent ) { |
| 769 | 841 | /** |
| @@ -781,10 +853,18 @@ | ||
| 781 | 853 | if ( empty( $get_discounts ) ) { |
| 782 | 854 | continue; |
| 783 | 855 | } |
| 784 | 856 | |
| 785 | - // Only stage campaigns whose discount actually applied to the cart. | |
| 786 | - ( 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; | |
| 787 | 867 | |
| 788 | 868 | foreach ( $get_discounts as $item_id => $discount ) { |
| 789 | 869 | $discounts[ $item_id ]['discounts'][] = max( $discount['discounts'] ); // phpcs:ignore |
| 790 | 870 | $discounts[ $item_id ]['free'] = $discount['free']; // phpcs:ignore |
| @@ -871,8 +951,24 @@ | ||
| 871 | 951 | $discounts['get_qty'] = empty( $reward_map ) ? 0 : max( $reward_map ); // phpcs:ignore |
| 872 | 952 | $discounts['free'] = ! empty( $reward_map ); // phpcs:ignore |
| 873 | 953 | $discounts['bogo_type'] = $bogo_type; |
| 874 | 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 | + } | |
| 875 | 971 | } |
| 876 | 972 | |
| 877 | 973 | return $discounts; |
| 878 | 974 | } |