Kristian checked out with an address in no group and was offered both free delivery methods set up for the Netic group. Measured on the shop before touching anything: one group, four rules, two of them "show" on the free delivery methods he saw. It was not a regression. A test asserted the behaviour as intended — "an unmatched customer sees every method" — so a "show" rule was a whitelist FOR THE GROUP and nothing more. The plugin did half of what its own description promises: it filtered what the group sees and never reserved anything from anyone else. On a shop that takes money the effect is that free delivery arranged for one company is available to every customer. A method named in any group's show rules is now reserved for the groups that name it. Answering "does it work for other discounts too", which found the hole the first fix left: it did not. A group with NO rules of its own returned every rate untouched, and so did a group carrying only "hide" rules, because neither engages the whitelist — so their members saw another group's reserved methods. Both now have other groups' reserved methods removed. The "never leave the customer without shipping" guard is kept and asserted: a shop that reserved everything it offers must still let an ordinary customer check out, because that failure is worse than the leak. Four assertions, each proven red. Two of them caught my own mistakes rather than the product's: the hide-only fixture originally left nothing for the customer, so the guard fired and returned every rate — which reads exactly like the leak it was written to detect. A third rate makes the behaviour observable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
464 lines
12 KiB
PHP
464 lines
12 KiB
PHP
<?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();
|
|
|
|
// $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;
|
|
}
|
|
}
|