Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wc-vendors/class-wc-vendors.php
+++ b/wc-vendors/class-wc-vendors.php
@@ -8,11 +8,11 @@
* Author URI: https://www.wcvendors.com
* GitHub Plugin URI: https://github.com/wcvendors/wcvendors
*
- * Version: 2.7.0
+ * Version: 2.7.1
* Requires at least: 5.9
* Tested up to: 7.0
* WC requires at least: 5.0
- * WC tested up to: 10.8
+ * WC tested up to: 10.9
*
* Text Domain: wc-vendors
* Domain Path: /languages/
@@ -145,7 +145,7 @@
}
if ( ! defined( 'WCV_VERSION' ) ) {
- define( 'WCV_VERSION', '2.7.0' );
+ define( 'WCV_VERSION', '2.7.1' );
}
if ( ! defined( 'WCV_TEMPLATE_BASE' ) ) {
--- a/wc-vendors/classes/admin/class-admin-menus.php
+++ b/wc-vendors/classes/admin/class-admin-menus.php
@@ -637,6 +637,7 @@
'wc-vendors-woocommerce-bookings',
'wc-vendors-gateway-stripe-connect',
'wc-vendors-pro',
+ 'wc-vendors-engage',
)
);
include WCV_ABSPATH_ADMIN . 'views/html-admin-about-page.php';
--- a/wc-vendors/classes/admin/class-wcv-plugin-installer.php
+++ b/wc-vendors/classes/admin/class-wcv-plugin-installer.php
@@ -244,6 +244,13 @@
'desc' => __( 'Integration with Simple Auctions plugin to create an auction marketplace just like eBay, Gumtree, or Facebook Marketplace. Allow your vendors to sell auction products right from their dashboard.', 'wc-vendors' ),
'upgrade_link' => 'https://www.wcvendors.com/pricing/?utm_source=plugin&utm_medium=extensionspage&utm_campaign=upgradesimpleauctionsaddon',
),
+ 'wc-vendors-engage' => array(
+ 'base_name' => 'wc-vendors-engage/wc-vendors-engage.php',
+ 'name' => __( 'WC Vendors Engage', 'wc-vendors' ),
+ 'logo' => WCV_ASSETS_URL . 'images/extensions/icon-cart.png',
+ 'desc' => __( 'Build customer loyalty with store followers and exclusive follower-only discounts. Let customers follow their favourite vendors, receive filtered product feeds, and automatically unlock follower discounts at checkout.', 'wc-vendors' ),
+ 'upgrade_link' => 'https://www.wcvendors.com/pricing/?utm_source=plugin&utm_medium=extensionspage&utm_campaign=upgradeengageaddon',
+ ),
'woocommerce' => array(
'base_name' => 'woocommerce/woocommerce.php',
'name' => __( 'WooCommerce', 'wc-vendors' ),
--- a/wc-vendors/classes/admin/settings/class-wcv-settings-capabilities.php
+++ b/wc-vendors/classes/admin/settings/class-wcv-settings-capabilities.php
@@ -445,7 +445,7 @@
),
array(
- 'title' => __( 'AI Moderate', 'wc-vendors' ),
+ 'title' => __( 'AI Product Moderation', 'wc-vendors' ),
'desc' => $this->get_ai_moderate_description(),
'id' => 'wcvendors_capability_ai_moderate',
'default' => 'no',
@@ -537,18 +537,18 @@
}
if ( ! wcv_is_plugin_installed( $plugin_basename ) ) {
- return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'Store Agent AI for WooCommerce plugin is required', 'wc-vendors' ) . ')</span>';
+ return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'StoreAgent AI for WooCommerce plugin is required', 'wc-vendors' ) . ')</span>';
}
if ( ! is_plugin_active( $plugin_basename ) ) {
- return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'Store Agent AI for WooCommerce plugin must be activated', 'wc-vendors' ) . ')</span>';
+ return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'StoreAgent AI for WooCommerce plugin must be activated', 'wc-vendors' ) . ')</span>';
}
// Check if Store Agent is connected.
if ( class_exists( 'SAAIHelpersConnect' ) ) {
$is_connected = SAAIHelpersConnect::is_connected();
if ( ! $is_connected ) {
- return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'You need to connect your store to Store Agent AI first', 'wc-vendors' ) . ')</span>';
+ return $desc . ' <span class="description" style="color: #d63638;">(' . __( 'You need to connect your store to StoreAgent AI first', 'wc-vendors' ) . ')</span>';
}
}
@@ -589,9 +589,9 @@
'store_agent_slug' => 'storeagent-ai-for-woocommerce',
'store_agent_dashboard_url' => admin_url( 'admin.php?page=storeagent-dashboard' ),
'store_agent_logo_url' => $store_agent_logo,
- 'i18n_store_agent_required' => __( 'Store Agent AI for WooCommerce is required for AI Moderate feature.', 'wc-vendors' ),
- 'i18n_step_1_title' => __( 'Step 1: Install and Activate Store Agent', 'wc-vendors' ),
- 'i18n_step_2_title' => __( 'Step 2: Connect to Store Agent', 'wc-vendors' ),
+ 'i18n_store_agent_required' => __( 'StoreAgent AI for WooCommerce is required for AI Product Moderation feature.', 'wc-vendors' ),
+ 'i18n_step_1_title' => __( 'Step 1: Install and Activate StoreAgent', 'wc-vendors' ),
+ 'i18n_step_2_title' => __( 'Step 2: Connect to StoreAgent', 'wc-vendors' ),
'i18n_install_and_activate' => __( 'Install and Activate', 'wc-vendors' ),
'i18n_activate' => __( 'Activate', 'wc-vendors' ),
'i18n_connect_store_agent' => __( 'Open Connection Page', 'wc-vendors' ),
@@ -600,11 +600,11 @@
'i18n_installing' => __( 'Installing...', 'wc-vendors' ),
'i18n_activating' => __( 'Activating...', 'wc-vendors' ),
'i18n_checking_connection' => __( 'Checking...', 'wc-vendors' ),
- 'i18n_connection_success' => __( 'Connection successful! Store Agent is ready.', 'wc-vendors' ),
- 'i18n_connection_not_ready' => __( 'Not connected yet. Please connect Store Agent and try again.', 'wc-vendors' ),
+ 'i18n_connection_success' => __( 'Connection successful! StoreAgent is ready.', 'wc-vendors' ),
+ 'i18n_connection_not_ready' => __( 'Not connected yet. Please connect StoreAgent and try again.', 'wc-vendors' ),
'i18n_connection_error' => __( 'Unable to verify connection. Please try again.', 'wc-vendors' ),
- 'i18n_install_success' => __( 'Store Agent installed and activated successfully!', 'wc-vendors' ),
- 'i18n_install_error' => __( 'Failed to install Store Agent. Please try again.', 'wc-vendors' ),
+ 'i18n_install_success' => __( 'StoreAgent installed and activated successfully!', 'wc-vendors' ),
+ 'i18n_install_error' => __( 'Failed to install StoreAgent. Please try again.', 'wc-vendors' ),
'i18n_step_completed' => __( 'Completed', 'wc-vendors' ),
)
);
@@ -637,9 +637,9 @@
// Template variables.
$store_agent_logo_url = $store_agent_logo;
$store_agent_dashboard_url = admin_url( 'admin.php?page=storeagent-dashboard' );
- $i18n_store_agent_required = __( 'Store Agent AI for WooCommerce is required for AI Moderate feature.', 'wc-vendors' );
- $i18n_step_1_title = __( 'Step 1: Install and Activate Store Agent', 'wc-vendors' );
- $i18n_step_2_title = __( 'Step 2: Connect to Store Agent', 'wc-vendors' );
+ $i18n_store_agent_required = __( 'StoreAgent AI for WooCommerce is required for AI Product Moderation feature.', 'wc-vendors' );
+ $i18n_step_1_title = __( 'Step 1: Install and Activate StoreAgent', 'wc-vendors' );
+ $i18n_step_2_title = __( 'Step 2: Connect to StoreAgent', 'wc-vendors' );
$i18n_install_and_activate = __( 'Install and Activate', 'wc-vendors' );
$i18n_connect_store_agent = __( 'Open Connection Page', 'wc-vendors' );
$i18n_check_connection = __( 'Check Connection', 'wc-vendors' );
--- a/wc-vendors/classes/admin/views/html-admin-ai-moderate-modal.php
+++ b/wc-vendors/classes/admin/views/html-admin-ai-moderate-modal.php
@@ -15,7 +15,7 @@
<div class="wcv-modal-header">
<button class="wcv-modal-close" type="button" aria-label="<?php echo esc_attr__( 'Close', 'wc-vendors' ); ?>">×</button>
<div class="wcv-modal-header-logo">
- <img src="<?php echo esc_url( $store_agent_logo_url ); ?>" alt="Store Agent Logo" />
+ <img src="<?php echo esc_url( $store_agent_logo_url ); ?>" alt="StoreAgent Logo" />
</div>
<h2><?php echo esc_html( $i18n_store_agent_required ); ?></h2>
</div>
--- a/wc-vendors/classes/class-shipping.php
+++ b/wc-vendors/classes/class-shipping.php
@@ -51,8 +51,8 @@
// Table Rate Shipping 2 by WooThemes.
if ( function_exists( 'woocommerce_get_shipping_method_table_rate' ) ) {
- add_action( 'woocommerce_checkout_update_order_meta', array( 'WCV_Shipping', 'trs2_add_shipping_data' ), 1, 1 );
- add_action( 'wc_trs2_matched_rates', array( 'WCV_Shipping', 'trs2_store_shipping_data' ), 10, 3 );
+ add_action( 'woocommerce_checkout_update_order_meta', array( $this, 'trs2_add_shipping_data' ), 1, 1 );
+ add_action( 'wc_trs2_matched_rates', array( $this, 'trs2_store_shipping_data' ), 10, 3 );
}
}
--- a/wc-vendors/classes/class-vendors.php
+++ b/wc-vendors/classes/class-vendors.php
@@ -608,13 +608,19 @@
*
* @param string|int $input The username or user ID.
*
- * @return int
+ * @return int|false
+ * @version 2.7.1 Added fast-path for numeric vendor IDs to skip the get_users() lookup.
*/
public static function get_vendor_id( $input ) {
if ( empty( $input ) ) {
return false;
}
+ // Numeric vendor ID needs no slug lookup; skip the get_users() query.
+ if ( is_numeric( $input ) && self::is_vendor( (int) $input ) ) {
+ return (int) $input;
+ }
+
$users = get_users(
array(
'meta_key' => 'pv_shop_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
--- a/wc-vendors/classes/front/class-wcv-order-controller.php
+++ b/wc-vendors/classes/front/class-wcv-order-controller.php
@@ -2366,9 +2366,14 @@
$order_ids = array();
+ // Customer info capabilities gate what a vendor may see. The search must honour them too,
+ // otherwise a vendor can confirm a customer's email/phone/name/address placed an order with
+ // them via a targeted search even though the value is never displayed. See issue #1849.
+ $customer_caps = $this->get_customer_search_capabilities();
+
switch ( $this->search_filter ) {
case 'customer':
- $order_ids = $this->search_orders_by_customer( $this->search_input );
+ $order_ids = $this->search_orders_by_customer( $this->search_input, $customer_caps );
break;
case 'product':
$order_ids = $this->search_orders_by_product( $this->search_input );
@@ -2377,7 +2382,7 @@
$order_ids = $this->search_orders_by_order_id( $this->search_input );
break;
default:
- $order_ids = $this->search_orders_by_all( $this->search_input );
+ $order_ids = $this->search_orders_by_all( $this->search_input, $customer_caps );
break;
}
@@ -2406,10 +2411,13 @@
/**
* Search orders by all criteria
*
- * @param string $search_input The search input.
+ * @param string $search_input The search input.
+ * @param array|null $customer_caps Customer info capabilities used to gate which customer
+ * fields may be matched (see get_customer_search_capabilities()).
+ * Null keeps all fields searchable (backwards compatible).
* @return array $orders The orders.
*/
- public function search_orders_by_all( $search_input ) {
+ public function search_orders_by_all( $search_input, $customer_caps = null ) {
global $wpdb;
$is_hpos = wcv_hpos_enabled();
@@ -2457,16 +2465,23 @@
$order_ids = $wpdb->get_col( $query ); // phpcs:ignore
+ // Drop candidates whose only match is a customer field the vendor is not allowed to see.
+ // Product name and order ID matches are not customer PII, so they are kept.
+ $order_ids = $this->filter_search_by_customer_capabilities( $order_ids, $search_input, $customer_caps, true );
+
return $order_ids;
}
/**
* Search orders by customer
*
- * @param string $search_input The search input.
+ * @param string $search_input The search input.
+ * @param array|null $customer_caps Customer info capabilities used to gate which customer
+ * fields may be matched (see get_customer_search_capabilities()).
+ * Null keeps all fields searchable (backwards compatible).
* @return array $orders The orders.
*/
- public function search_orders_by_customer( $search_input ) {
+ public function search_orders_by_customer( $search_input, $customer_caps = null ) {
global $wpdb;
$is_hpos = wcv_hpos_enabled();
@@ -2503,6 +2518,11 @@
// phpcs:enable
$order_ids = $wpdb->get_col( $query ); // phpcs:ignore
+
+ // The only match vector here is customer info, so drop any candidate whose match relied on
+ // a customer field the vendor is not allowed to see.
+ $order_ids = $this->filter_search_by_customer_capabilities( $order_ids, $search_input, $customer_caps, false );
+
return $order_ids;
}
@@ -2587,4 +2607,192 @@
$order_ids = $wpdb->get_col( $query ); // phpcs:ignore
return $order_ids;
}
+
+ /**
+ * Get the customer info capabilities that gate order search.
+ *
+ * Each flag mirrors a "Capabilities" setting. When a flag is false the matching customer field
+ * must not be discoverable through the order search box.
+ *
+ * @since 2.7.1
+ *
+ * @return array {
+ * @type bool $name Customer (billing) name.
+ * @type bool $shipping_name Customer shipping name.
+ * @type bool $billing Customer billing address (incl. company).
+ * @type bool $shipping Customer shipping address (incl. company).
+ * @type bool $email Customer email.
+ * @type bool $phone Customer phone (billing and shipping).
+ * }
+ */
+ protected function get_customer_search_capabilities() {
+ // Default fallbacks match the display side (wcv-dashboard-functions.php, emails, exports),
+ // which all default to 'no'. This keeps search hiding exactly what the dashboard hides when
+ // an option has never been saved, instead of leaving a field discoverable via search.
+ return array(
+ 'name' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_name', 'no' ) ),
+ 'shipping_name' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_shipping_name', 'no' ) ),
+ 'billing' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_billing', 'no' ) ),
+ 'shipping' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_shipping', 'no' ) ),
+ 'email' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_email', 'no' ) ),
+ 'phone' => wc_string_to_bool( get_option( 'wcvendors_capability_order_customer_phone', 'no' ) ),
+ );
+ }
+
+ /**
+ * Remove search results that only matched on a customer field the vendor may not view.
+ *
+ * The order search relies on WooCommerce's address index, which bundles every customer field
+ * (name, address, email, phone) into one blob, so the SQL cannot tell which field matched. This
+ * re-checks each candidate against only the fields whose capability is enabled, using
+ * WooCommerce getters so it works under both HPOS and legacy storage.
+ *
+ * @since 2.7.1
+ *
+ * @param array $order_ids Candidate vendor sub-order IDs from the SQL search.
+ * @param string $search_input The raw search term.
+ * @param array|null $customer_caps Capabilities from get_customer_search_capabilities(), or
+ * null to keep every field searchable.
+ * @param bool $match_non_customer Whether product name / order ID matches also keep a
+ * candidate (true for the "all" filter).
+ * @return array Filtered order IDs.
+ */
+ protected function filter_search_by_customer_capabilities( $order_ids, $search_input, $customer_caps, $match_non_customer ) {
+
+ // Nothing to strip when no capabilities were supplied or every field is allowed.
+ if ( empty( $order_ids ) || null === $customer_caps || ! in_array( false, $customer_caps, true ) ) {
+ return $order_ids;
+ }
+
+ $needle = trim( $search_input );
+ if ( '' === $needle ) {
+ return $order_ids;
+ }
+
+ // Batch load the candidate sub-orders and their parents up front so the loop below issues a
+ // fixed number of queries instead of one per candidate. This path only runs when at least
+ // one capability is disabled, but the search result set can still be large.
+ $sub_by_id = array();
+ $parent_ids = array();
+ $sub_order_args = array(
+ 'type' => 'shop_order_vendor',
+ 'post__in' => $order_ids,
+ 'limit' => -1,
+ );
+ foreach ( wc_get_orders( $sub_order_args ) as $sub_order ) {
+ $sub_by_id[ $sub_order->get_id() ] = $sub_order;
+ if ( $sub_order->get_parent_id() ) {
+ $parent_ids[] = $sub_order->get_parent_id();
+ }
+ }
+
+ $parent_by_id = array();
+ if ( ! empty( $parent_ids ) ) {
+ $parent_args = array(
+ 'post__in' => array_unique( $parent_ids ),
+ 'limit' => -1,
+ );
+ foreach ( wc_get_orders( $parent_args ) as $parent_order ) {
+ $parent_by_id[ $parent_order->get_id() ] = $parent_order;
+ }
+ }
+
+ $filtered = array();
+
+ foreach ( $order_ids as $order_id ) {
+ $sub_order = isset( $sub_by_id[ $order_id ] ) ? $sub_by_id[ $order_id ] : null;
+ if ( ! $sub_order ) {
+ continue;
+ }
+
+ // Customer details live on the parent order. Fall back to the sub-order when there is no
+ // parent, or when the parent could not be loaded (e.g. it was deleted).
+ $parent_id = $sub_order->get_parent_id();
+ $parent_order = ( $parent_id && isset( $parent_by_id[ $parent_id ] ) ) ? $parent_by_id[ $parent_id ] : $sub_order;
+
+ $haystack = $this->build_customer_search_haystack( $parent_order, $customer_caps );
+ $matched = ( '' !== $haystack && false !== stripos( $haystack, $needle ) );
+
+ // Product name and order ID are not customer PII, so they keep the candidate on "all".
+ if ( ! $matched && $match_non_customer ) {
+ if ( (string) $parent_order->get_id() === $needle ) {
+ $matched = true;
+ } else {
+ foreach ( $sub_order->get_items() as $item ) {
+ if ( false !== stripos( $item->get_name(), $needle ) ) {
+ $matched = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if ( $matched ) {
+ $filtered[] = $order_id;
+ }
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * Build a search haystack from only the customer fields the vendor is allowed to view.
+ *
+ * @since 2.7.1
+ *
+ * @param WC_Order $order The (parent) order carrying the customer details.
+ * @param array $customer_caps Capabilities from get_customer_search_capabilities().
+ * @return string Space separated searchable text.
+ */
+ protected function build_customer_search_haystack( $order, $customer_caps ) {
+
+ // Field order here need not match WooCommerce's concatenated address index. We only test each
+ // enabled field for a substring match, so ordering is irrelevant except in the rare case of a
+ // term that straddles two adjacent fields in WC's index; that edge is accepted as a benign
+ // false negative in exchange for only exposing fields the vendor is allowed to view.
+ $parts = array();
+
+ if ( ! empty( $customer_caps['name'] ) ) {
+ $parts[] = $order->get_billing_first_name();
+ $parts[] = $order->get_billing_last_name();
+ }
+
+ if ( ! empty( $customer_caps['shipping_name'] ) ) {
+ $parts[] = $order->get_shipping_first_name();
+ $parts[] = $order->get_shipping_last_name();
+ }
+
+ if ( ! empty( $customer_caps['billing'] ) ) {
+ $parts[] = $order->get_billing_company();
+ $parts[] = $order->get_billing_address_1();
+ $parts[] = $order->get_billing_address_2();
+ $parts[] = $order->get_billing_city();
+ $parts[] = $order->get_billing_state();
+ $parts[] = $order->get_billing_postcode();
+ $parts[] = $order->get_billing_country();
+ }
+
+ if ( ! empty( $customer_caps['shipping'] ) ) {
+ $parts[] = $order->get_shipping_company();
+ $parts[] = $order->get_shipping_address_1();
+ $parts[] = $order->get_shipping_address_2();
+ $parts[] = $order->get_shipping_city();
+ $parts[] = $order->get_shipping_state();
+ $parts[] = $order->get_shipping_postcode();
+ $parts[] = $order->get_shipping_country();
+ }
+
+ if ( ! empty( $customer_caps['email'] ) ) {
+ $parts[] = $order->get_billing_email();
+ }
+
+ if ( ! empty( $customer_caps['phone'] ) ) {
+ $parts[] = $order->get_billing_phone();
+ if ( is_callable( array( $order, 'get_shipping_phone' ) ) ) {
+ $parts[] = $order->get_shipping_phone();
+ }
+ }
+
+ return trim( implode( ' ', array_filter( $parts ) ) );
+ }
}
--- a/wc-vendors/classes/front/class-wcv-table-helper.php
+++ b/wc-vendors/classes/front/class-wcv-table-helper.php
@@ -365,7 +365,7 @@
$this->get_action_column();
// display the table.
- wcv_deprecated_filter( 'wcvendors_pro_table_before_' . $this->id, '2.5.2', 'wcvendors_table_before_' . $this->id, $this->id, 'before' );
+ wcv_deprecated_action( 'wcvendors_pro_table_before_' . $this->id, '2.5.2', 'wcvendors_table_before_' . $this->id, $this->id, 'before' );
$no_data_notice = wcv_deprecated_filter(
'wcvendors_pro_table_no_data_notice_' . $this->id,
--- a/wc-vendors/classes/includes/api/admin/class-wcv-admin-api.php
+++ b/wc-vendors/classes/includes/api/admin/class-wcv-admin-api.php
@@ -271,6 +271,8 @@
/**
* Custom query to search customers.
*
+ * @since 2.7.1 Replaced extract() with explicit assignments; status is now allow-listed and prepared.
+ *
* @param array $params Array of parameters.
*
* @return array $results Tuple of results and total results.
@@ -288,7 +290,12 @@
),
);
- extract( $params ); // phpcs:ignore
+ // Assign only the known parameters explicitly. Using extract() here would
+ // create/overwrite arbitrary locals from request input (e.g. $wpdb).
+ $search = (string) $params['search'];
+ $status = (string) $params['status'];
+ $limit = absint( $params['limit'] );
+ $page = max( 1, absint( $params['page'] ) );
$offset = ( $page - 1 ) * $limit;
@@ -326,7 +333,11 @@
if ( 'pending' === $status ) {
$where_query = "AND ucap.meta_value LIKE '%"pending_vendor"%'";
} else {
- $where_query .= "AND vstatus.meta_value = '{$status}' AND ucap.meta_value NOT LIKE '%"pending_vendor"%' ";
+ $status = in_array( $status, array( 'active', 'inactive' ), true ) ? $status : 'active';
+ $where_query .= $wpdb->prepare(
+ "AND vstatus.meta_value = %s AND ucap.meta_value NOT LIKE '%%"pending_vendor"%%' ", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- %% is the correct prepare() escape for a literal % in a LIKE pattern; pending_vendor is a hardcoded role slug, not user input.
+ $status
+ );
}
}
@@ -363,7 +374,7 @@
// Get vendor counts.
$vendor_count = $this->_get_vendor_count_for_all_status();
- // TODO: sanitize parameter values.
+ // Parameter values are sanitized in _query_vendor_ids() (allow-list + $wpdb->prepare).
$params = $request->get_params();
// Query the vendor IDs based on the provided parameters.
--- a/wc-vendors/classes/includes/class-wcv-shortcodes.php
+++ b/wc-vendors/classes/includes/class-wcv-shortcodes.php
@@ -1082,11 +1082,12 @@
)
);
+ // Validate per_page before it is used to compute the offset, and floor
+ // it to 1 to avoid a divide-by-zero when paginating below.
+ $per_page = is_numeric( $per_page ) ? max( 1, absint( $per_page ) ) : 12;
+
$paged = ( get_query_var( 'paged' ) ) ? get_query_var( 'paged' ) : 1;
$offset = ( $paged - 1 ) * $per_page;
- if ( ! is_numeric( $per_page ) ) {
- $per_page = 12;
- }
if ( ! is_numeric( $columns ) ) {
$columns = 4;
@@ -1191,6 +1192,13 @@
$paged_vendors = $wpdb->get_results( $vendor_paged_sql ); // phpcs:ignore
$total_vendors = $wpdb->get_var( 'SELECT FOUND_ROWS()' ); // phpcs:ignore
+ // Prime user + usermeta caches once so the per-vendor reads below
+ // (and during rendering) hit cache instead of querying per vendor.
+ $vendor_ids = wp_list_pluck( $paged_vendors, 'ID' );
+ if ( ! empty( $vendor_ids ) ) {
+ cache_users( $vendor_ids );
+ }
+
// Process vendor data.
$vendors = array();
foreach ( $paged_vendors as $vendor ) {
@@ -1198,17 +1206,12 @@
$wp_u->ID = $vendor->ID;
$wp_u->product_count = $vendor->product_count;
- // Get vendor meta in one efficient query instead of multiple calls.
- $vendor_meta = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
- $wpdb->prepare(
- "SELECT meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d",
- $vendor->ID
- ),
- ARRAY_A
- );
+ // get_user_meta() with no key returns array( meta_key => array( values ) ) from
+ // the primed cache, so [0] takes the first value before unserializing.
+ $vendor_meta = get_user_meta( $vendor->ID );
- foreach ( $vendor_meta as $meta ) {
- $wp_u->{$meta['meta_key']} = maybe_unserialize( $meta['meta_value'] );
+ foreach ( $vendor_meta as $meta_key => $meta_values ) {
+ $wp_u->{$meta_key} = maybe_unserialize( $meta_values[0] );
}
$vendors[] = $wp_u;
--- a/wc-vendors/templates/dashboard/reports/reports.php
+++ b/wc-vendors/templates/dashboard/reports/reports.php
@@ -7,46 +7,57 @@
* @package WC_Vendors
* @version 1.8.0
* @version 2.6.5 - Fix security issues.
+ * @version 2.7.1 - Add Total Refunded Sales row and calculate Total Commission and Net Revenue on net sales.
*
* @phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
*/
?>
<?php
- // Single Vendor Total Gross Sales.
+ // Single Vendor Total Gross Sales, Refunded Sales and Commission.
$give_tax = wc_string_to_bool( get_option( 'wcvendors_vendor_give_taxes', 'no' ) );
$give_shipping = is_wcv_pro_active() && wc_string_to_bool( get_option( 'wcvendors_vendor_give_shipping', 'no' ) );
- $gross_sales_totals = $store_report->orders;
- $vendor_order_total = 0;
- foreach ( $gross_sales_totals as $gross_sales_total ) {
- $vendor_total_sales = $gross_sales_total->total;
- $vendor_order_total = $vendor_order_total + $vendor_total_sales;
- }
-
- // Single Vendor Total Commission.
- $product_commissions_totals = $store_report->orders;
- $commissionTotal = 0;
- $net_revenue = 0;
- $total_tax = 0;
- $total_shipping = 0;
-
- foreach ( $product_commissions_totals as $product_commissions_total ) {
- $vendor_commission_data = $product_commissions_total->commission_total;
- $commissionTotal = $vendor_commission_data + $commissionTotal;
-
- if ( $give_tax ) {
- $vendor_tax_data = $product_commissions_total->total_tax;
- $total_tax += $vendor_tax_data;
- }
-
- if ( $give_shipping ) {
- $vendor_shipping_data = $product_commissions_total->total_shipping;
- $total_shipping += $vendor_shipping_data;
+ $vendor_order_total = 0; // Total Gross Sales (pre-refund, all orders).
+ $vendor_refunded_total = 0; // Total Refunded Sales (reversed commission rows).
+ $commission_total = 0; // Total Commission on net sales (reversed rows excluded).
+ $net_revenue = 0;
+ $total_tax = 0;
+ $total_shipping = 0;
+
+ foreach ( $store_report->orders as $store_order ) {
+ // Gross Sales stays pre-refund: include every order.
+ $vendor_order_total += $store_order->total;
+
+ foreach ( $store_order->vendor_products as $vendor_product ) {
+ // Refunded product: record its sales value, exclude its commission from the totals.
+ if ( 'reversed' === $vendor_product->status ) {
+ // No matching order item (product removed from order): commission is still excluded,
+ // but its sales value cannot be recovered, so Refunded Sales will understate.
+ $refunded_item = $store_order->order_items[ $vendor_product->product_id ] ?? null;
+ if ( $refunded_item ) {
+ // Prefer the actual refunded amount; fall back to the full line total for
+ // reversals with no refund record (DB-level / status-transition reversals).
+ $parent_order = $refunded_item->get_order();
+ $refunded_amt = $parent_order ? (float) $parent_order->get_total_refunded_for_item( $refunded_item->get_id() ) : 0;
+ $vendor_refunded_total += $refunded_amt > 0 ? $refunded_amt : (float) $refunded_item->get_total();
+ }
+ continue;
+ }
+
+ $commission_total += $vendor_product->total_due + $vendor_product->total_shipping + $vendor_product->tax;
+
+ if ( $give_tax ) {
+ $total_tax += $vendor_product->tax;
+ }
+
+ if ( $give_shipping ) {
+ $total_shipping += $vendor_product->total_shipping;
+ }
}
}
- $net_revenue = $commissionTotal - $total_tax - $total_shipping;
+ $net_revenue = $commission_total - $total_tax - $total_shipping;
?>
@@ -72,9 +83,16 @@
<td><strong><?php echo wp_kses( wc_price( $vendor_order_total ), wcv_allowed_html_tags() ); ?></strong></td>
</tr>
+ <?php if ( $vendor_refunded_total > 0 ) : ?>
+ <tr>
+ <th><?php esc_html_e( 'Total Refunded Sales', 'wc-vendors' ); ?></th>
+ <td><strong><?php echo wp_kses( wc_price( $vendor_refunded_total ), wcv_allowed_html_tags() ); ?></strong></td>
+ </tr>
+ <?php endif; ?>
+
<tr>
<th><?php esc_html_e( 'Total Commission', 'wc-vendors' ); ?></th>
- <td><strong><?php echo wp_kses( wc_price( $commissionTotal ), wcv_allowed_html_tags() ); ?></strong></td>
+ <td><strong><?php echo wp_kses( wc_price( $commission_total ), wcv_allowed_html_tags() ); ?></strong></td>
</tr>
<tr>