2026-08-23 15:38:14 +02:00
|
|
|
<?php
|
|
|
|
|
/**
|
|
|
|
|
* Frontend / checkout integration.
|
|
|
|
|
*
|
|
|
|
|
* @package WC_Email_Discount_Manager
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Applies discounts, auto-applies coupons and filters shipping methods on checkout.
|
|
|
|
|
*/
|
|
|
|
|
class WC_EDM_Frontend {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Session key holding the tracked guest email.
|
|
|
|
|
*/
|
|
|
|
|
const SESSION_EMAIL_KEY = 'wc_edm_email';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Guard so the coupon is only auto-applied once per request.
|
|
|
|
|
*
|
|
|
|
|
* @var bool
|
|
|
|
|
*/
|
|
|
|
|
private $coupon_applied = false;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Hook everything up.
|
|
|
|
|
*/
|
|
|
|
|
public function __construct() {
|
|
|
|
|
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
|
|
|
|
|
|
|
|
|
|
add_action( 'wp_ajax_wc_edm_track_email', array( $this, 'ajax_track_email' ) );
|
|
|
|
|
add_action( 'wp_ajax_nopriv_wc_edm_track_email', array( $this, 'ajax_track_email' ) );
|
|
|
|
|
|
|
|
|
|
add_action( 'woocommerce_cart_calculate_fees', array( $this, 'apply_discount_fees' ), 20 );
|
|
|
|
|
add_action( 'woocommerce_before_calculate_totals', array( $this, 'maybe_apply_coupon' ), 20 );
|
|
|
|
|
add_filter( 'woocommerce_package_rates', array( $this, 'filter_shipping_rates' ), 20, 2 );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enqueue the checkout email tracking script.
|
|
|
|
|
*/
|
|
|
|
|
public function enqueue_scripts() {
|
|
|
|
|
if ( ! function_exists( 'is_checkout' ) || ( ! is_checkout() && ! is_cart() ) ) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$params = array(
|
|
|
|
|
'ajax_url' => 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();
|
|
|
|
|
|
2026-09-22 09:46:48 +02:00
|
|
|
// $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 ) {
|
2026-08-23 15:38:14 +02:00
|
|
|
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
|
2026-09-22 09:46:48 +02:00
|
|
|
} 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 );
|
2026-08-23 15:38:14 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-22 09:46:48 +02:00
|
|
|
if ( $match && ! empty( $match->free_shipping ) ) {
|
2026-08-23 15:38:14 +02:00
|
|
|
foreach ( $rates as $rate ) {
|
|
|
|
|
$rate->set_cost( 0 );
|
|
|
|
|
$rate->set_taxes( array() );
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $rates;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 09:46:48 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 15:38:14 +02:00
|
|
|
/**
|
|
|
|
|
* 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 );
|
|
|
|
|
|
2026-09-22 09:46:48 +02:00
|
|
|
// 🔴 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.
|
2026-08-23 15:38:14 +02:00
|
|
|
if ( empty( $rules ) ) {
|
2026-09-22 09:46:48 +02:00
|
|
|
return $this->remove_restricted_rates( $rates );
|
2026-08-23 15:38:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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();
|
|
|
|
|
|
2026-09-22 09:46:48 +02:00
|
|
|
// Reserved by some group, but not by THIS one.
|
|
|
|
|
$others = array_diff( WC_EDM_DB::restricted_shipping_methods(), $show );
|
|
|
|
|
|
2026-08-23 15:38:14 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 09:46:48 +02:00
|
|
|
if ( in_array( $rate_id, $others, true ) ) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 15:38:14 +02:00
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
}
|