admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'wc-edm-track-email' ), ); $script = ' jQuery( function( $ ) { var wcEdm = ' . wp_json_encode( $params ) . '; var timer = null; var lastTracked = ""; function trackEmail( email ) { if ( ! email || email.indexOf( "@" ) < 1 || email === lastTracked ) { return; } lastTracked = email; $.post( wcEdm.ajax_url, { action: "wc_edm_track_email", nonce: wcEdm.nonce, email: email }, function() { $( document.body ).trigger( "update_checkout" ); } ); } $( document.body ).on( "keyup change blur", "#billing_email", function() { var email = $( this ).val(); clearTimeout( timer ); timer = setTimeout( function() { trackEmail( email ); }, 600 ); } ); // Track a prefilled email on page load. var initial = $( "#billing_email" ).val(); if ( initial ) { trackEmail( initial ); } } );'; wp_add_inline_script( 'jquery', $script ); } /** * AJAX: store the checkout email in the WooCommerce session so * discounts apply when totals are recalculated. */ public function ajax_track_email() { check_ajax_referer( 'wc-edm-track-email', 'nonce' ); $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; if ( ! WC()->session ) { wp_send_json_error( array( 'message' => 'No session.' ) ); } if ( $email && is_email( $email ) ) { WC()->session->set( self::SESSION_EMAIL_KEY, $email ); } else { WC()->session->set( self::SESSION_EMAIL_KEY, null ); } $this->clear_shipping_cache(); $match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null; wp_send_json_success( array( 'matched' => (bool) $match, ) ); } /** * Clear cached shipping rates so shipping rules re-evaluate. */ private function clear_shipping_cache() { if ( ! WC()->cart || ! WC()->session ) { return; } $packages = WC()->cart->get_shipping_packages(); foreach ( array_keys( $packages ) as $index ) { WC()->session->set( 'shipping_for_package_' . $index, null ); } } /** * Resolve the email address of the current customer. * * Logged-in users use their account email; guests use the tracked * session email or the billing email WooCommerce already knows. * * @return string */ public function get_customer_email() { if ( is_user_logged_in() ) { $user = wp_get_current_user(); if ( $user && $user->user_email ) { return $user->user_email; } } if ( WC()->session ) { $session_email = WC()->session->get( self::SESSION_EMAIL_KEY ); if ( $session_email ) { return $session_email; } } if ( WC()->customer ) { $billing_email = WC()->customer->get_billing_email(); if ( $billing_email ) { return $billing_email; } } return ''; } /** * Get the current customer's email assignment match, if any. * * @return object|null */ private function get_current_match() { $email = $this->get_customer_email(); if ( ! $email ) { return null; } return WC_EDM_Helpers::find_email_match( $email ); } /** * Apply group/custom discounts as negative fees. * * @param WC_Cart $cart Cart object. */ public function apply_discount_fees( $cart ) { if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { return; } $match = $this->get_current_match(); if ( ! $match ) { return; } if ( 'group' === $match->assignment_type ) { $this->apply_group_fee( $cart, $match ); } elseif ( 'custom' === $match->assignment_type ) { $this->apply_custom_fee( $cart, $match ); } } /** * Group discount: percentage off cart items in the group's categories. * * @param WC_Cart $cart Cart object. * @param object $match Email assignment row. */ private function apply_group_fee( $cart, $match ) { if ( ! $match->group_id ) { return; } $group = WC_EDM_DB::get_group( (int) $match->group_id ); if ( ! $group || (float) $group->discount_percent <= 0 ) { return; } $category_ids = WC_EDM_DB::get_group_category_ids( (int) $group->id ); if ( empty( $category_ids ) ) { return; } $eligible_total = 0; foreach ( $cart->get_cart() as $cart_item ) { $product_id = (int) $cart_item['product_id']; // Parent ID for variations. if ( has_term( $category_ids, 'product_cat', $product_id ) ) { $eligible_total += (float) $cart_item['line_total']; } } if ( $eligible_total <= 0 ) { return; } $discount = $eligible_total * ( (float) $group->discount_percent / 100 ); $cart->add_fee( /* translators: %s: discount group name */ sprintf( __( 'Email discount: %s', 'wc-email-discount' ), $group->name ), -$discount, false ); } /** * Custom discount: percentage off the entire cart contents. * * @param WC_Cart $cart Cart object. * @param object $match Email assignment row. */ private function apply_custom_fee( $cart, $match ) { $percent = (float) $match->custom_discount; if ( $percent <= 0 ) { return; } $cart_total = 0; foreach ( $cart->get_cart() as $cart_item ) { $cart_total += (float) $cart_item['line_total']; } if ( $cart_total <= 0 ) { return; } $discount = $cart_total * ( $percent / 100 ); $cart->add_fee( /* translators: %s: discount percent */ sprintf( __( 'Email discount (%s%%)', 'wc-email-discount' ), wc_format_localized_decimal( $percent ) ), -$discount, false ); } /** * Auto-apply a coupon for coupon-type assignments. * * @param WC_Cart $cart Cart object. */ public function maybe_apply_coupon( $cart ) { if ( $this->coupon_applied || ( is_admin() && ! defined( 'DOING_AJAX' ) ) ) { return; } $match = $this->get_current_match(); if ( ! $match || 'coupon' !== $match->assignment_type || empty( $match->coupon_code ) ) { return; } $code = wc_format_coupon_code( $match->coupon_code ); if ( ! wc_get_coupon_id_by_code( $code ) ) { return; } if ( $cart->has_discount( $code ) ) { return; } $this->coupon_applied = true; $cart->apply_coupon( $code ); } /** * Filter shipping rates for the matched email assignment. * * Group assignments get their show/hide rules applied; any assignment * with the free shipping flag gets all remaining rates at zero cost. * * @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate). * @param array $package Shipping package. * @return array */ public function filter_shipping_rates( $rates, $package ) { $match = $this->get_current_match(); // $match is null for a customer who matches nothing — the common case, // and the one this whole method exists to constrain now. if ( $match && 'group' === $match->assignment_type && $match->group_id ) { $rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id ); } else { // 🔴 Everyone else — including a matched customer whose assignment // is not a group — must not see a method some group reserved. // // This used to return $rates untouched, and a test asserted that // as intended: "an unmatched customer sees every method". It made // a "show" rule a whitelist FOR THE GROUP and nothing more, so // reserving free delivery for approved addresses left it on the // checkout for every other customer. Reported from a live shop, // where a gmail address could pick a free-delivery method meant // for one company. $rates = $this->remove_restricted_rates( $rates ); } if ( $match && ! empty( $match->free_shipping ) ) { foreach ( $rates as $rate ) { $rate->set_cost( 0 ); $rate->set_taxes( array() ); } } return $rates; } /** * Remove every method some group reserved for itself. * * @param array $rates Available shipping rates. * @return array */ private function remove_restricted_rates( $rates ) { $restricted = WC_EDM_DB::restricted_shipping_methods(); if ( empty( $restricted ) ) { return $rates; } $filtered = array(); foreach ( $rates as $rate_id => $rate ) { if ( ! in_array( $rate_id, $restricted, true ) ) { $filtered[ $rate_id ] = $rate; } } // The same guard the group rules carry: a shop that reserved every // method it offers would otherwise leave an ordinary customer unable // to check out at all, which is worse than the leak being fixed. return empty( $filtered ) ? $rates : $filtered; } /** * Apply a group's show/hide shipping rules. * * If the group has no rules, all rates pass through untouched. * "show" rules act as a whitelist; "hide" rules remove rates. * Rates are reordered to follow the rules' sort order. * * @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate). * @param int $group_id Discount group ID. * @return array */ private function apply_group_shipping_rules( $rates, $group_id ) { $rules = WC_EDM_DB::get_shipping_rules( $group_id ); // 🔴 Not `return $rates` — a group with no rules of its own must still // not see what ANOTHER group reserved. Same for a group carrying only // "hide" rules: its own shown-set is empty, so the whitelist below // never engages and every other group's reserved method would survive. // The leak this fixes was reported for customers matching nothing; it // existed for these two shapes as well. if ( empty( $rules ) ) { return $this->remove_restricted_rates( $rates ); } $show = array(); $hide = array(); foreach ( $rules as $rule ) { if ( 'show' === $rule->action ) { $show[] = $rule->shipping_method_id; } else { $hide[] = $rule->shipping_method_id; } } $filtered = array(); // Reserved by some group, but not by THIS one. $others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show ); foreach ( $rates as $rate_id => $rate ) { // Whitelist mode: only explicitly shown methods survive. if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) { continue; } if ( in_array( $rate_id, $hide, true ) ) { continue; } if ( in_array( $rate_id, $others, true ) ) { continue; } $filtered[ $rate_id ] = $rate; } // Never leave the customer without shipping because of misconfigured rules. if ( empty( $filtered ) ) { return $rates; } // Reorder rates to follow the rules' sort order; unmatched rates keep their position at the end. $rule_order = wp_list_pluck( $rules, 'shipping_method_id' ); $ordered = array(); foreach ( $rule_order as $method_id ) { if ( isset( $filtered[ $method_id ] ) ) { $ordered[ $method_id ] = $filtered[ $method_id ]; unset( $filtered[ $method_id ] ); } } return $ordered + $filtered; } }