--- a/woocommerce-wholesale-prices/includes/class-wwp-admin-settings.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-admin-settings.php
@@ -170,7 +170,7 @@
*/
public function permission_admin_check( $request ) { // phpcs:ignore.
- if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error( 'rest_forbidden', esc_html__( 'You do not have permission to save data.', 'woocommerce-wholesale-prices' ), array( 'status' => 403 ) );
}
@@ -194,24 +194,10 @@
'message' => esc_html__( 'Settings saved successfully.', 'woocommerce-wholesale-prices' ),
);
- $allowed_prefix = array(
- 'wwp_',
- 'wwpp_',
- 'wwof_',
- 'wwlc_',
- 'wpay_',
- );
- $allowed_prefix = apply_filters( 'wwp_allowed_settings_prefix', $allowed_prefix );
-
- $prefix_pattern = '/^(' . implode(
- '|',
- array_map(
- function ( $prefix ) {
- return preg_quote( $prefix, '/' );
- },
- $allowed_prefix
- )
- ) . ')/';
+ // Deprecate the old prefix-based filter.
+ if ( has_filter( 'wwp_allowed_settings_prefix' ) ) {
+ _deprecated_hook( 'wwp_allowed_settings_prefix', '2.2.7', 'wwp_allowed_settings_keys' );
+ }
if ( ! empty( $params ) ) {
@@ -224,29 +210,10 @@
}
}
- $ignored_keys = array(
- 'action',
- 'enable_wholesale_role_cart_quantity_based_wholesale_discount',
- 'enable_wholesale_role_cart_quantity_based_wholesale_discount_mode_2',
- 'enable_wholesale_role_cart_only_apply_discount_if_min_order_req_met',
- 'apply_discounts_to_wholesale_products_only',
- );
- $ignored_keys = apply_filters( 'wwp_ignored_settings_keys', $ignored_keys );
-
$options = array();
foreach ( $params as $param ) {
- if ( ! $has_action && ! in_array( $param['key'], $ignored_keys, true ) ) {
- $is_allowed = false;
- if ( isset( $param['key'] ) && preg_match( $prefix_pattern, $param['key'] ) ) {
- $is_allowed = true;
- }
-
- if ( ! $is_allowed ) {
- continue;
- }
- }
-
if ( is_array( $param ) && isset( $param['key'] ) ) {
+ $key = sanitize_key( $param['key'] );
$value = isset( $param['value'] ) ? $param['value'] : '';
$final_value = $value;
@@ -259,17 +226,38 @@
}
}
- $options[ $param['key'] ] = $final_value;
+ $options[ $key ] = $final_value;
}
}
- if ( ! empty( $options['action'] ) ) {
- // trigger the custom group save action.
- $settings_messages = apply_filters( 'wwp_group_settings_' . $options['action'], $options );
+ if ( $has_action && ! empty( $options['action'] ) ) {
+ $action_name = sanitize_key( $options['action'] );
+ $allowed_actions = $this->get_allowed_group_actions();
+
+ if ( ! in_array( $action_name, $allowed_actions, true ) ) {
+ return rest_ensure_response(
+ array(
+ 'status' => 'error',
+ 'message' => esc_html__( 'Invalid action.', 'woocommerce-wholesale-prices' ),
+ )
+ );
+ }
+
+ // Trigger the custom group save action.
+ $settings_messages = apply_filters( 'wwp_group_settings_' . $action_name, $options );
} else {
+ // Regular save path: validate keys against explicit allowlist.
+ $allowed_keys = $this->get_allowed_option_keys();
+ $type_map = $this->get_control_type_map();
+
foreach ( $options as $option_name => $option_value ) {
- $setting_value = $option_value;
- $arr_value = json_decode( $setting_value, true );
+ if ( ! in_array( $option_name, $allowed_keys, true ) ) {
+ continue;
+ }
+
+ $setting_value = $this->sanitize_option_value( $option_value, $option_name, $type_map );
+
+ $arr_value = is_string( $setting_value ) ? json_decode( $setting_value, true ) : null;
if ( is_array( $arr_value ) ) {
$setting_value = $arr_value;
}
@@ -303,8 +291,23 @@
);
if ( ! empty( $params ) && ! empty( $params['action'] ) ) {
- // trigger the custom group save action.
- $settings_messages = apply_filters( 'wwp_trigger_' . $params['action'], $params );
+ $action_name = sanitize_key( $params['action'] );
+ $allowed_actions = $this->get_allowed_trigger_actions();
+
+ if ( ! in_array( $action_name, $allowed_actions, true ) ) {
+ return rest_ensure_response(
+ array(
+ 'status' => 'error',
+ 'message' => esc_html__( 'Invalid action.', 'woocommerce-wholesale-prices' ),
+ )
+ );
+ }
+
+ // Sanitize all params before passing to filter.
+ $params = map_deep( $params, 'sanitize_text_field' );
+
+ // Trigger the custom action.
+ $settings_messages = apply_filters( 'wwp_trigger_' . $action_name, $params );
}
$response = array(
@@ -321,6 +324,257 @@
}
/**
+ * Get the list of allowed option keys derived from registered tab settings controls.
+ *
+ * Walks the controls tree returned by get_registered_tab_settings() and extracts
+ * all 'id' values to build an explicit allowlist. Additional keys can be added
+ * via the 'wwp_allowed_settings_keys' filter.
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @return array Flat array of allowed option key strings.
+ */
+ private function get_allowed_option_keys() {
+ $settings = $this->get_registered_tab_settings();
+ $keys = array();
+
+ if ( ! empty( $settings['controls'] ) && is_array( $settings['controls'] ) ) {
+ array_walk_recursive(
+ $settings['controls'],
+ function ( $value, $key ) use ( &$keys ) {
+ if ( 'id' === $key && is_string( $value ) ) {
+ $keys[] = $value;
+ }
+ }
+ );
+ }
+
+ /**
+ * Filter the list of allowed settings keys for the REST API save endpoint.
+ *
+ * @since 2.2.7
+ *
+ * @param array $keys Allowed option key strings.
+ */
+ $keys = apply_filters( 'wwp_allowed_settings_keys', $keys );
+
+ return array_unique( $keys );
+ }
+
+ /**
+ * Build a type map from registered controls (id => type).
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @return array Associative array of option_key => control_type.
+ */
+ private function get_control_type_map() {
+ $settings = $this->get_registered_tab_settings();
+ $type_map = array();
+
+ if ( ! empty( $settings['controls'] ) && is_array( $settings['controls'] ) ) {
+ $this->extract_type_map( $settings['controls'], $type_map );
+ }
+
+ return $type_map;
+ }
+
+ /**
+ * Recursively extract id => type pairs and option choices from controls.
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @param array $controls The controls array to walk.
+ * @param array $type_map Reference to the type map being built.
+ */
+ private function extract_type_map( $controls, &$type_map ) {
+ foreach ( $controls as $value ) {
+ if ( is_array( $value ) ) {
+ if ( isset( $value['id'], $value['type'] ) ) {
+ $type_map[ $value['id'] ] = array(
+ 'type' => $value['type'],
+ 'options' => isset( $value['options'] ) ? $value['options'] : array(),
+ 'editor' => ! empty( $value['editor'] ),
+ 'multiple' => ! empty( $value['multiple'] ),
+ );
+ }
+ $this->extract_type_map( $value, $type_map );
+ }
+ }
+ }
+
+ /**
+ * Sanitize an option value based on the control type.
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @param mixed $value The value to sanitize.
+ * @param string $option_key The option key.
+ * @param array $type_map Type map from get_control_type_map().
+ * @return mixed Sanitized value.
+ */
+ private function sanitize_option_value( $value, $option_key, $type_map ) {
+ if ( is_array( $value ) ) {
+ return map_deep( $value, 'sanitize_text_field' );
+ }
+
+ if ( ! isset( $type_map[ $option_key ] ) ) {
+ return sanitize_text_field( $value );
+ }
+
+ $control = $type_map[ $option_key ];
+
+ switch ( $control['type'] ) {
+ case 'checkbox':
+ case 'switch':
+ if ( ! empty( $control['options'] ) ) {
+ $allowed = array_keys( $control['options'] );
+ if ( in_array( $value, $allowed, true ) ) {
+ return $value;
+ }
+ }
+ // Default checkbox values.
+ return in_array( $value, array( 'yes', 'no' ), true ) ? $value : 'no';
+
+ case 'select':
+ case 'radio':
+ if ( ! empty( $control['options'] ) ) {
+ $allowed = array_keys( $control['options'] );
+
+ // Handle multi-select: value may be a JSON-encoded array.
+ if ( ! empty( $control['multiple'] ) ) {
+ $decoded = is_string( $value ) ? json_decode( $value, true ) : null;
+ if ( is_array( $decoded ) ) {
+ $sanitized = array_values( array_intersect( $decoded, $allowed ) );
+ return wp_json_encode( $sanitized );
+ }
+ }
+
+ if ( in_array( $value, $allowed, true ) ) {
+ return $value;
+ }
+ // Return first option as default if invalid.
+ return ! empty( $allowed ) ? reset( $allowed ) : '';
+ }
+ return sanitize_text_field( $value );
+
+ case 'number':
+ case 'money':
+ case 'percent':
+ if ( is_numeric( $value ) ) {
+ return $value;
+ }
+ return '';
+
+ case 'textarea':
+ if ( $control['editor'] ) {
+ return wp_kses_post( $value );
+ }
+ return sanitize_textarea_field( $value );
+
+ default:
+ return sanitize_text_field( $value );
+ }
+ }
+
+ /**
+ * Get the list of allowed group action names.
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @return array Array of allowed group action slug strings.
+ */
+ private function get_allowed_group_actions() {
+ // WWP core group actions.
+ $actions = array(
+ 'group_save',
+ 'group_delete',
+ 'group_edit',
+ );
+
+ // Auto-detect group actions registered by companion plugins via WordPress hooks.
+ // This ensures backwards compatibility with older plugin versions that register
+ // 'wwp_group_settings_*' hooks but don't yet use the 'wwp_allowed_group_actions' filter.
+ $actions = array_merge( $actions, $this->detect_registered_hooks( 'wwp_group_settings_' ) );
+
+ /**
+ * Filter the list of allowed group actions for the REST API save endpoint.
+ *
+ * Companion plugins should add their group action slugs via this filter.
+ *
+ * @since 2.2.7
+ *
+ * @param array $actions Allowed group action slug strings.
+ */
+ return array_unique( apply_filters( 'wwp_allowed_group_actions', $actions ) );
+ }
+
+ /**
+ * Get the list of allowed trigger action names.
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @return array Array of allowed trigger action slug strings.
+ */
+ private function get_allowed_trigger_actions() {
+ // Auto-detect trigger actions registered by companion plugins via WordPress hooks.
+ // This ensures backwards compatibility with older plugin versions that register
+ // 'wwp_trigger_*' hooks but don't yet use the 'wwp_allowed_trigger_actions' filter.
+ $actions = $this->detect_registered_hooks( 'wwp_trigger_' );
+
+ /**
+ * Filter the list of allowed trigger actions for the REST API action endpoint.
+ *
+ * Companion plugins should add their trigger action slugs via this filter.
+ *
+ * @since 2.2.7
+ *
+ * @param array $actions Allowed trigger action slug strings.
+ */
+ return array_unique( apply_filters( 'wwp_allowed_trigger_actions', $actions ) );
+ }
+
+ /**
+ * Detect registered WordPress hooks matching a given prefix and extract action slugs.
+ *
+ * Scans the global $wp_filter for hooks that start with the given prefix and returns
+ * the action slug portion (everything after the prefix). This provides backwards
+ * compatibility with companion plugins that register hooks but haven't yet been
+ * updated to use the explicit allowlist filters.
+ *
+ * @since 2.2.7
+ * @access private
+ *
+ * @param string $prefix The hook prefix to match (e.g. 'wwp_group_settings_' or 'wwp_trigger_').
+ * @return array Array of action slug strings extracted from matching hooks.
+ */
+ private function detect_registered_hooks( $prefix ) {
+ global $wp_filter;
+
+ $actions = array();
+ $prefix_length = strlen( $prefix );
+
+ if ( ! empty( $wp_filter ) && is_array( $wp_filter ) ) {
+ foreach ( array_keys( $wp_filter ) as $hook_name ) {
+ if ( 0 === strpos( $hook_name, $prefix ) ) {
+ $slug = substr( $hook_name, $prefix_length );
+ if ( ! empty( $slug ) ) {
+ $actions[] = $slug;
+ }
+ }
+ }
+ }
+
+ return $actions;
+ }
+
+ /**
* Get settings details.
*
* @since 2.0
@@ -1650,7 +1904,7 @@
* @access public
*/
public function wwp_new_settings_notice_hide() {
- if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_new_settings_notice_nonce' ) ) { // phpcs:ignore.
+ if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_new_settings_notice_nonce' ) ) {
// Security check failure.
return;
}
--- a/woocommerce-wholesale-prices/includes/class-wwp-bootstrap.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-bootstrap.php
@@ -414,7 +414,7 @@
* @access public
*/
public function wwp_getting_started_notice_hide() {
- if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_getting_started_nonce' ) ) { //phpcs:ignore
+ if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_getting_started_nonce' ) ) {
// Security check failure.
return;
}
--- a/woocommerce-wholesale-prices/includes/class-wwp-helper-functions.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-helper-functions.php
@@ -1506,5 +1506,20 @@
return $icon_url;
}
+ /**
+ * Verify an AJAX request by checking wp_doing_ajax(), nonce existence, and nonce validity.
+ *
+ * @param string $nonce_action The nonce action name to verify against.
+ *
+ * @since 2.2.7
+ * @access public
+ *
+ * @return boolean True if the AJAX request passes all security checks, false otherwise.
+ */
+ public static function verify_ajax_nonce( $nonce_action ) {
+ return wp_doing_ajax()
+ && isset( $_POST['nonce'] )
+ && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), $nonce_action );
+ }
}
}
--- a/woocommerce-wholesale-prices/includes/class-wwp-marketing.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-marketing.php
@@ -259,7 +259,7 @@
* @access public
*/
public function wwp_hide_acfwf_install_notice() {
- if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_hide_acfwf_install_notice_nonce' ) ) { //phpcs:ignore
+ if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_hide_acfwf_install_notice_nonce' ) ) {
// Security check failure.
return;
}
@@ -321,7 +321,7 @@
*/
public function ajax_request_review_response() {
- if ( ! wp_doing_ajax() || ! wp_verify_nonce( $_POST['nonce'], 'wwp_request_review_nonce' ) ) { //phpcs:ignore
+ if ( ! WWP_Helper_Functions::verify_ajax_nonce( 'wwp_request_review_nonce' ) ) {
$response = array(
'status' => 'fail',
'error_msg' => __( 'Security check failure', 'woocommerce-wholesale-prices' ),
@@ -330,8 +330,8 @@
echo wp_json_encode( $response );
wp_die();
- } elseif ( ! isset( $_POST['review_request_response'] ) ||
- ! in_array( $_POST['review_request_response'], array( 'review-later', 'review', 'never-show' ), true ) ) {
+ } elseif ( ! isset( $_POST['review_request_response'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified via WWP_Helper_Functions::verify_ajax_nonce() above.
+ ! in_array( sanitize_text_field( wp_unslash( $_POST['review_request_response'] ) ), array( 'review-later', 'review', 'never-show' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
$response = array(
'status' => 'fail',
@@ -343,7 +343,7 @@
} else {
// Sanitize.
- $review_request_response = sanitize_text_field( $_POST['review_request_response'] ); //phpcs:ignore
+ $review_request_response = sanitize_text_field( wp_unslash( $_POST['review_request_response'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
switch ( $review_request_response ) {
case 'review-later':
--- a/woocommerce-wholesale-prices/includes/class-wwp-script-loader.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-script-loader.php
@@ -526,7 +526,7 @@
*/
if (
! WWP_Helper_Functions::is_wwpp_active() &&
- isset( $_GET['tab'] ) && 'wwp_settings' === $_GET['tab'] // phpcs:ignore
+ isset( $_GET['tab'] ) && 'wwp_settings' === sanitize_key( wp_unslash( $_GET['tab'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
) {
// Queue up stuff that is used on all tabs.
@@ -539,8 +539,8 @@
wp_enqueue_style( 'wwp-free-training-guide-css', WWP_CSS_URL . 'backend/wwp-free-training-guide.css', array(), $this->_wwp_current_version, 'all' );
// Handle each section of the settings (General, Price, Tax, Upgrade).
- if ( isset( $_GET['section'] ) && '' !== $_GET['section'] ) { // phpcs:ignore
- switch ( $_GET['section'] ) { // phpcs:ignore
+ if ( isset( $_GET['section'] ) && '' !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ switch ( sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
case 'wwpp_setting_price_section':
wp_enqueue_script( 'wwp-price-settings', WWP_JS_URL . 'backend/wwp-price-setting.js', array( 'select2' ), $this->_wwp_current_version, true );
wp_localize_script(
@@ -631,8 +631,8 @@
// General page.
wp_enqueue_style( 'wwp-general-css', WWP_CSS_URL . 'wwp-general-settings.css', array(), $this->_wwp_current_version, 'all' );
}
- } elseif ( isset( $_GET['section'] ) ) { // phpcs:ignore
- switch ( $_GET['section'] ) { // phpcs:ignore
+ } elseif ( isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ switch ( sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
case 'wwpp_setting_price_section':
$this->load_wwp_prices_settings_for_non_wholesale_users_styles_and_scripts();
break;
@@ -978,7 +978,7 @@
* @since 2.1.2
*/
public function wchome_wws_upgrade_to_premium() {
- if ( isset( $_GET['page'] ) && 'wchome-wws-upgrade' === $_GET['page'] ) { // phpcs:ignore
+ if ( isset( $_GET['page'] ) && 'wchome-wws-upgrade' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
wp_safe_redirect( esc_url_raw( WWP_Helper_Functions::get_utm_url( 'bundle', 'wwp', 'upsell', 'wchomeupgradelink' ) ) );
exit;
}
@@ -1003,7 +1003,7 @@
* @access public
*/
public function load_wws_license_upsell_upgrade_to_premium_styles_and_scripts() {
- if ( isset( $_GET['page'] ) && $_GET['page'] == 'wws-license-settings' ) { // phpcs:ignore
+ if ( isset( $_GET['page'] ) && 'wws-license-settings' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
wp_enqueue_style( 'wws-wwp-license-upsell-upgrade-css', WWP_CSS_URL . 'backend/wwp-license-upsell-upgrade.css', array(), $this->_wwp_current_version, 'all' );
}
}
--- a/woocommerce-wholesale-prices/includes/class-wwp-settings.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-settings.php
@@ -696,7 +696,7 @@
*/
public function render_license_upgrade_content() {
- if ( isset( $_GET['section'] ) && 'wwp_license_section' === $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET['section'] ) && 'wwp_license_section' === sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
wp_safe_redirect( admin_url( 'admin.php?page=wws-license-settings' ) );
exit;
}
@@ -776,7 +776,7 @@
if ( WWP_Helper_Functions::is_wwpp_active() ) {
if ( isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- switch ( $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ switch ( sanitize_key( wp_unslash( $_GET['section'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
case 'wwpp_setting_price_section':
$dummy_settings_to_remove = array(
'wwp_settings_explicitly_use_product_regular_price_on_discount_calc_dummy',
--- a/woocommerce-wholesale-prices/includes/class-wwp-wholesale-prices.php
+++ b/woocommerce-wholesale-prices/includes/class-wwp-wholesale-prices.php
@@ -1626,8 +1626,120 @@
}
/**
+ * Get the general discount percentage for a wholesale role.
+ *
+ * General discounts are configured in WWPP and stored as an option mapping
+ * wholesale roles to percentage discounts. This helper retrieves the discount
+ * for the given role, returning 0 if none is set or if WWPP is not active.
+ *
+ * @since 2.2.7
+ *
+ * @param string $wholesale_role The wholesale role key.
+ *
+ * @return float The discount percentage, or 0 if none set.
+ */
+ public static function get_general_discount_for_role( $wholesale_role ) {
+
+ $discount_mapping = get_option( 'wwpp_option_wholesale_role_general_discount_mapping', array() );
+
+ if ( ! empty( $discount_mapping ) && is_array( $discount_mapping ) && isset( $discount_mapping[ $wholesale_role ] ) ) {
+ return (float) $discount_mapping[ $wholesale_role ];
+ }
+
+ return 0;
+ }
+
+ /**
+ * Build the wholesale price filter meta query for a given role and price range.
+ *
+ * Constructs an OR meta query matching products with per-product wholesale prices,
+ * variable product wholesale prices, and (if a general discount is set) products
+ * whose retail price falls within the reverse-calculated range. Also provides a
+ * filter hook for premium plugins (e.g. WWPP) to add category-level conditions.
+ *
+ * @since 2.2.7
+ *
+ * @param string $role_key The sanitized wholesale role key.
+ * @param float $min_price The minimum wholesale price to filter.
+ * @param float $max_price The maximum wholesale price to filter.
+ *
+ * @return array The meta query array.
+ */
+ private function build_wholesale_price_filter_meta_query( $role_key, $min_price, $max_price ) {
+
+ $meta_key = $role_key . '_wholesale_price';
+ $meta_key_variations = $role_key . '_variations_with_wholesale_price';
+
+ $meta_query = array(
+ 'relation' => 'OR',
+ array(
+ 'key' => $meta_key,
+ 'value' => array( $min_price, $max_price ),
+ 'compare' => 'BETWEEN',
+ 'type' => 'NUMERIC',
+ ),
+ array(
+ 'key' => $meta_key_variations,
+ 'value' => array( $min_price, $max_price ),
+ 'compare' => 'BETWEEN',
+ 'type' => 'NUMERIC',
+ ),
+ );
+
+ // Include products that receive wholesale pricing via general discount rules.
+ $general_discount = self::get_general_discount_for_role( $role_key );
+
+ if ( $general_discount > 0 ) {
+ // Reverse-calculate: wholesale = retail * (1 - discount/100)
+ // Therefore: retail = wholesale / (1 - discount/100).
+ $multiplier = 1 - ( $general_discount / 100 );
+
+ // Guard against 100% discount (division by zero).
+ if ( $multiplier > 0 ) {
+ $adjusted_min = $min_price / $multiplier;
+ $adjusted_max = $max_price / $multiplier;
+
+ $meta_query[] = array(
+ 'relation' => 'AND',
+ array(
+ 'key' => '_price',
+ 'value' => array( $adjusted_min, $adjusted_max ),
+ 'compare' => 'BETWEEN',
+ 'type' => 'DECIMAL(10,2)',
+ ),
+ array(
+ 'key' => $meta_key,
+ 'compare' => 'NOT EXISTS',
+ ),
+ array(
+ 'key' => $meta_key_variations,
+ 'compare' => 'NOT EXISTS',
+ ),
+ );
+ }
+ }
+
+ /**
+ * Filter the wholesale price filter meta query.
+ *
+ * Allows premium plugins (e.g. WWPP) to add conditions for category-level
+ * wholesale pricing or other custom pricing rules.
+ *
+ * @since 2.2.7
+ *
+ * @param array $meta_query The meta query array with OR relation.
+ * @param string $role_key The wholesale role key.
+ * @param float $min_price The minimum wholesale price filter value.
+ * @param float $max_price The maximum wholesale price filter value.
+ */
+ return apply_filters( 'wwp_wholesale_price_filter_meta_query', $meta_query, $role_key, $min_price, $max_price );
+ }
+
+ /**
* Configure Maximum/Minimum Values for WooCommerce Products Shortcode for Compatibility with the HUSKY – Products Filter Professional for WooCommerce Plugin.
*
+ * @since 2.2.7 Added general discount and category-level wholesale price filtering support.
+ *
* @param array $query WordPress query object.
* @param array $atts attributes set for woocommerce.
* @param string $type post_type for woocoomerce which is 'post_type'.
@@ -1650,25 +1762,7 @@
return $query;
}
- $meta_key = $role_key . '_wholesale_price';
- $meta_key_variations = $role_key . '_variations_with_wholesale_price';
-
- $meta_query = array(
- 'relation' => 'OR',
- array(
- 'key' => $meta_key,
- 'value' => array( $min_price, $max_price ),
- 'compare' => 'BETWEEN',
- 'type' => 'NUMERIC',
- ),
- array(
- 'key' => $meta_key_variations,
- 'value' => array( $min_price, $max_price ),
- 'compare' => 'BETWEEN',
- 'type' => 'NUMERIC',
- ),
- );
- $query['meta_query'] = $meta_query;
+ $query['meta_query'] = $this->build_wholesale_price_filter_meta_query( $role_key, $min_price, $max_price ); //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
return $query;
}
@@ -1678,7 +1772,9 @@
* Convert into float.
*
* @since 2.2.5
+ *
* @param int|string $value The subject value.
+ *
* @return float
*/
private function absfloat( $value ) {
@@ -1689,8 +1785,10 @@
/**
* Configure Maximum/Minimum Values in WooCommerce Product Queries for Compatibility with the HUSKY – Products Filter Professional for WooCommerce Plugin.
*
+ * @since 2.2.7 Added general discount and category-level wholesale price filtering support.
+ *
* @param object $product_query WooCommerce product query object.
- * @param object $wc_object WooCommerce product query object.
+ * @param object $wc_object WooCommerce product query object.
*
* @return void
*/
@@ -1709,27 +1807,196 @@
return;
}
- $meta_key = $role_key . '_wholesale_price';
- $meta_key_variations = $role_key . '_variations_with_wholesale_price';
+ $product_query->set( 'meta_query', $this->build_wholesale_price_filter_meta_query( $role_key, $min_price, $max_price ) ); //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ }
+ }
+
+ /**
+ * Filter the minimum price for the WooCommerce price filter widget to use wholesale prices.
+ *
+ * When a wholesale customer views the shop, the price filter widget slider should
+ * reflect wholesale price ranges instead of retail prices.
+ *
+ * @since 2.2.7
+ *
+ * @param float $min_price The minimum price from WooCommerce.
+ *
+ * @return float The wholesale minimum price, or the original if no wholesale role.
+ */
+ public function wholesale_price_filter_widget_min_amount( $min_price ) {
+ return $this->get_wholesale_price_for_filter( $min_price, 'MIN' );
+ }
+
+ /**
+ * Filter the maximum price for the WooCommerce price filter widget to use wholesale prices.
+ *
+ * When a wholesale customer views the shop, the price filter widget slider should
+ * reflect wholesale price ranges instead of retail prices.
+ *
+ * @since 2.2.7
+ *
+ * @param float $max_price The maximum price from WooCommerce.
+ *
+ * @return float The wholesale maximum price, or the original if no wholesale role.
+ */
+ public function wholesale_price_filter_widget_max_amount( $max_price ) {
+ return $this->get_wholesale_price_for_filter( $max_price, 'MAX' );
+ }
- $meta_query = array(
- 'relation' => 'OR',
- array(
- 'key' => $meta_key,
- 'value' => array( $min_price, $max_price ),
- 'compare' => 'BETWEEN',
- 'type' => 'NUMERIC',
- ),
- array(
- 'key' => $meta_key_variations,
- 'value' => array( $min_price, $max_price ),
- 'compare' => 'BETWEEN',
- 'type' => 'NUMERIC',
- ),
- );
- $product_query->set( 'meta_query', $meta_query );
+ /**
+ * Get the wholesale price for the price filter widget.
+ *
+ * Validates the current user's wholesale role and returns the appropriate
+ * wholesale price range value, falling back to the original price.
+ *
+ * @since 2.2.7
+ *
+ * @param float $fallback_price The default price from WooCommerce.
+ * @param string $aggregate 'MIN' or 'MAX'.
+ *
+ * @return float The wholesale price, or the fallback if no wholesale role.
+ */
+ private function get_wholesale_price_for_filter( $fallback_price, $aggregate ) {
+
+ $user_wholesale_role = $this->_wwp_wholesale_roles->getUserWholesaleRole();
+
+ if ( empty( $user_wholesale_role ) ) {
+ return $fallback_price;
+ }
+
+ $role_key = sanitize_key( $user_wholesale_role[0] );
+ $all_registered_wholesale_roles = $this->_wwp_wholesale_roles->getAllRegisteredWholesaleRoles();
+
+ if ( ! isset( $all_registered_wholesale_roles[ $role_key ] ) ) {
+ return $fallback_price;
}
+
+ $wholesale_price = $this->get_wholesale_price_range_value( $role_key, $aggregate );
+
+ return null !== $wholesale_price ? $wholesale_price : $fallback_price;
}
+
+ /**
+ * Get the minimum or maximum wholesale price across all published products for a role.
+ *
+ * Considers per-product wholesale prices, variable product wholesale prices,
+ * and general discount applied to retail prices.
+ *
+ * @since 2.2.7
+ *
+ * @param string $role_key The wholesale role key.
+ * @param string $aggregate 'MIN' or 'MAX'.
+ *
+ * @return float|null The aggregated price, or null if no wholesale prices found.
+ */
+ private function get_wholesale_price_range_value( $role_key, $aggregate ) {
+
+ $cache_key = 'wwp_price_range_' . $role_key . '_' . strtolower( $aggregate );
+ $cached = get_transient( $cache_key );
+
+ if ( false !== $cached ) {
+ return $cached;
+ }
+
+ global $wpdb;
+
+ $is_max = 'MAX' === strtoupper( $aggregate );
+ $sql_func = $is_max ? 'MAX' : 'MIN';
+ $meta_key = $role_key . '_wholesale_price';
+
+ // Get min/max per-product wholesale price.
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $per_product_price = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT {$sql_func}(CAST(pm.meta_value AS DECIMAL(10,2)))
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE pm.meta_key = %s
+ AND pm.meta_value > 0
+ AND p.post_status = 'publish'",
+ $meta_key
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $prices = array();
+
+ if ( null !== $per_product_price ) {
+ $prices[] = (float) $per_product_price;
+ }
+
+ // Also consider retail prices with general discount applied.
+ $general_discount = self::get_general_discount_for_role( $role_key );
+
+ if ( $general_discount > 0 ) {
+ $multiplier = 1 - ( $general_discount / 100 );
+
+ if ( $multiplier > 0 ) {
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $retail_price = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT {$sql_func}(CAST(pm.meta_value AS DECIMAL(10,2)))
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ WHERE pm.meta_key = %s
+ AND pm.meta_value > 0
+ AND p.post_status = 'publish'",
+ '_price'
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( null !== $retail_price ) {
+ $prices[] = (float) $retail_price * $multiplier;
+ }
+ }
+ }
+
+ /**
+ * Filter the wholesale price range values for the price filter widget.
+ *
+ * Allows premium plugins to include category-level or other custom pricing.
+ *
+ * @since 2.2.7
+ *
+ * @param array $prices Array of candidate prices.
+ * @param string $role_key The wholesale role key.
+ * @param string $aggregate 'MIN' or 'MAX'.
+ */
+ $prices = apply_filters( 'wwp_wholesale_price_filter_widget_prices', $prices, $role_key, $aggregate );
+
+ if ( empty( $prices ) ) {
+ return null;
+ }
+
+ $result = 'MIN' === $aggregate ? min( $prices ) : max( $prices );
+
+ set_transient( $cache_key, $result, HOUR_IN_SECONDS );
+
+ return $result;
+ }
+
+ /**
+ * Clear cached wholesale price range transients.
+ *
+ * Called when products are saved or wholesale prices are updated,
+ * ensuring the price filter widget reflects current data.
+ *
+ * @since 2.2.7
+ *
+ * @return void
+ */
+ public static function clear_wholesale_price_range_cache() {
+
+ $wholesale_roles = WWP_Wholesale_Roles::getInstance();
+ $all_roles = $wholesale_roles->getAllRegisteredWholesaleRoles();
+
+ foreach ( array_keys( $all_roles ) as $role_key ) {
+ delete_transient( 'wwp_price_range_' . $role_key . '_min' );
+ delete_transient( 'wwp_price_range_' . $role_key . '_max' );
+ }
+ }
+
/**
* Execute model.
*
@@ -1863,6 +2130,14 @@
// Modify WooCommerce Shortcode Filtering for Compatibility with the HUSKY – WooCommerce Products Filter Plugin, Filter Products by Price.
add_filter( 'woocommerce_shortcode_products_query', array( $this, 'woocommerce_shortcode_products_query' ), 99999, 3 );
+ // Adjust price filter widget min/max range to use wholesale prices for wholesale customers.
+ add_filter( 'woocommerce_price_filter_widget_min_amount', array( $this, 'wholesale_price_filter_widget_min_amount' ), 10, 1 );
+ add_filter( 'woocommerce_price_filter_widget_max_amount', array( $this, 'wholesale_price_filter_widget_max_amount' ), 10, 1 );
+
+ // Clear wholesale price range cache when products are updated.
+ add_action( 'woocommerce_update_product', array( __CLASS__, 'clear_wholesale_price_range_cache' ) );
+ add_action( 'update_option_wwpp_option_wholesale_role_general_discount_mapping', array( __CLASS__, 'clear_wholesale_price_range_cache' ) );
+
// Register the option for translation.
add_filter( 'pre_update_option', array( $this, 'wwp_wpml_translatable_options' ), 100, 2 );
}
--- a/woocommerce-wholesale-prices/woocommerce-wholesale-prices.bootstrap.php
+++ b/woocommerce-wholesale-prices/woocommerce-wholesale-prices.bootstrap.php
@@ -5,7 +5,7 @@
* Plugin URI: https://wholesalesuiteplugin.com
* Description: WooCommerce Extension to Provide Wholesale Prices Functionality
* Author: Rymera Web Co
- * Version: 2.2.6
+ * Version: 2.2.7
* Author URI: http://rymera.com.au/
* Text Domain: woocommerce-wholesale-prices
* Requires at least: 5.2
--- a/woocommerce-wholesale-prices/woocommerce-wholesale-prices.plugin.php
+++ b/woocommerce-wholesale-prices/woocommerce-wholesale-prices.plugin.php
@@ -111,7 +111,7 @@
public $wwp_plugin_installer;
// phpcs:enable
- const VERSION = '2.2.6';
+ const VERSION = '2.2.7';
/**
* Class Methods