The code in this article is a customisation. As such, it's provided as is, without implicit or explicit warranties, and it's outside the scope of our support service. Should you need assistance implementing the code, please feel free to contact us to request a consultation.


1. Introduction

The sorting logic built in WooCommerce relies on a query that loads data directly from the database. The prices used for sorting are fetched from a product lookup table, which only contains product prices in shop's base currency. This logic works as expected in a single-currency scenario, because each product only has one price, which doesn't change unless someone edits it, or imports it from an external source. In a multi-currency scenario, the same sorting can also work fine, as long as each product has a price in the shop's base currency, and the price is consistent with the additional currency prices. 


When these conditions are respected, WooCommerce can sort the products using the prices in the shop's base currency, which would be in the correct order. However, there are two scenarios in which the sorting might yield a different result:

  1. Some product prices in shop's base currency might be lower or higher than their prices in other currencies. For example, product A could have a price of 100 USD (base currency) and 90 EUR (additional currency), whereas product B could have a price of 95 USD and 95 EUR. Sorting the products by price, in ascending order, should show the following:
    - Product B, then product A when the active currency is USD.
    - Product A, then product B when the active currency is EUR.
    Since the sorting is based only on the shop's base currency, the products would always be sorted as Product A, then Product B.
  2. Some products might not have a price in the shop's base currency at all. The Currency Switcher allows to set the base currency for each product, so that Product A could have USD prices (base currency) and have EUR prices calculated from USD, while Product B could have EUR prices and have USD prices (base currency) calculated from EUR. In this case, the prices in the lookup table used for the sorting would be empty for Product B. Since the sorting logic can't read the prices that the Currency Switcher calculates on the fly, it would fetch the empty prices from the database, and assume, incorrectly, that product B has a price of zero.


In these scenarios, WooCommerce could sort the products in the wrong order, because it's relying on incorrect or incomplete data. To prevent this issue, it would be necessary to implement a new sorting logic that can use dynamically calculated prices. However, this would require a significant rewrite of the WooCommerce core.


2. Experimental solution

Since it's not possible to change how the WooCommerce core work, we prepared an experimental patch for the sorting logic, which works as follows:

  1. Whenever a product price is calculated in a currency, the patch stores it in some custom meta, linking it to the currency. 
  2. When the products are sorted by price, the patch alters the query that fetches the prices so that it uses the values stored in the product meta.

This approach allows the query to read the prices calculated in each currency, and use those values for a more accurate sorting. 


2.1 Important notes

  1. This patch is experimental. Our tests indicates that it should work, but we can't provide any guarantee about its suitability for a specific purpose, nor that the sorting will work correctly in all cases.
  2. This patch is designed for the built-in sorting logic used by WooCommerce. Other plugins, or themes, could use their own algorithm, which might not be supported by this solution. In that case, your developers should adapt the code to the specific plugin or theme in use.
  3. This code supports the following product types:
    - Simple
    - Variable
    - External
    - Grouped
    If you need to support other product types (e.g. bundles, subscriptions, etc), the code provides a filter that for that purpose.
  4. The code follows the approach of "eventually consistent" data. It does so by updating the prices used for the sorting "on the fly" for each product, whenever a product is loaded, in order to minimise the impact on the site's performance. The side effect of this approach is that the product prices may take some time before they are fully populated, as only the products that are actually loaded get processed. Over time, depending on the amount of traffic and on which products are loaded, the calculated prices prices going to be fully updated, and the sorting will become more accurate. 

    Tip: If you need to speed up the calculations, you can do so by opening the various pages in the catalogue, after choosing different currencies could help. This would load the products and generate the sorting prices used by the algorithm.
  5. The code doesn't implement a "batch update" feature for the sorting prices. Such operation should be performed in the background, one set of products at a time, but it could end up being heavy and time consuming, potentially slowing down the site significantly. Due to that, we omitted that feature from this example.
  6. Since the sorting logic needs the product prices to be stored in the database, this code could create a large number of entries in the post meta (two prices for each product). This is normal. Uninstalling the code will also delete the meta automatically.


3. Code snippet

The following code snippet is ready to use. It should be packed as a small plugin, so that you can enable it and disable it easily. This will also allow the code to trigger the cleanup of the data if the plugin gets uninstalled. If you need guidance on how to create a custom plugin, the following tutorials explain the steps:


You can paste the whole content of the snippet inside the plugin file and save it. You will then be able to see the plugin in the WordPress Dashboard and enable it like any other.


Disclaimer

 This code is experimental, the use is at your own risk. Please make sure that you understand what it does, before using it. We recommend to test it on a staging site, before running it on a live site. We strongly recommend involving your developers during the testing, so that they can identify possible issues in good time.

/*
Plugin Name: Aelia - Currency Price Sorting
Plugin URI: https://aelia.co/
Description: Alters the logic used by WooCommerce to sort by price, to use the currency-specific prices
Version: 1.0.1.230329
Author: Aelia
Author URI: https://aelia.co
License: GPLv3
*/
namespace Aelia\WC\Customisation;

use \WC_Product;
use \WC_Product_Variable;

/**
 * Implements a custom sorting logic to use currency-specific prices.
 */
class WC_Aelia_Currency_Price_Sorting {
	protected static $_sorting_by_price = false;
	protected static $_sort_order = 'ASC';

	const TEXT_DOMAIN = 'aelia-currency-price-sorting';

	/**
	 * Initialises the class.
	 *
	 * @return void
	 */
	public static function init(): void {
		add_filter('woocommerce_product_query_meta_query', [__CLASS__, 'woocommerce_product_query_meta_query'], 99, 2);
		add_filter('woocommerce_product_get_price', [__CLASS__, 'woocommerce_product_get_price'], 3, 2);
	}

	/**
	 * Indicates if the current query should be sorted based on the currency-specific prices.
	 *
	 * @return boolean
	 */
	protected static function should_sort_query_by_currency_prices(): bool {
		return (!is_admin() || defined('DOING_AJAX')) && static::$_sorting_by_price;
	}

	/**
	 * Intercepts the meta query being prepared by WooCommerce. This method uses the
	 * "woocommerce_product_query_meta_query" filter as an entry point to determine if
	 * the sorting logic should be altered. It doesn't modify the meta query passed to it.
	 *
	 * @param array $meta_query
	 * @param WC_Query $query
	 * @return array
	 */
	public static function woocommerce_product_query_meta_query($meta_query, $query) {
		$catalog_ordering_args = $query->get_catalog_ordering_args();

		static::$_sorting_by_price = in_array('price', explode(' ', $catalog_ordering_args['orderby'] ?? ''));
		static::$_sort_order = $catalog_ordering_args['order'] ?? 'ASC';

		if(!static::should_sort_query_by_currency_prices()) {
			return $meta_query;
		}

		// Remove the sorting logic used by WooCommerce to "sort by price, ascending" and
		// replace it with the custom one
		if(has_filter('posts_clauses', [$query, 'order_by_price_desc_post_clauses'])) {
			remove_filter('posts_clauses', [$query, 'order_by_price_desc_post_clauses']);

			add_filter('posts_clauses', [__CLASS__, 'posts_clauses'], 999, 2);
		}

		// Remove the sorting logic used by WooCommerce to "sort by price, decending" and
		// replace it with the custom one
		if(has_filter('posts_clauses', [$query, 'order_by_price_asc_post_clauses'])) {
			remove_filter('posts_clauses', [$query, 'order_by_price_asc_post_clauses']);

			add_filter('posts_clauses', [__CLASS__, 'posts_clauses'], 999, 2);
		}

		// Return the meta, untouched. We only used this event to hook into the flow at the right time
		return $meta_query;
	}

	/**
	 * Intercepts the post clauses used to build the product query, adding the JOIN and ORDER BY
	 * clauses needed to sort the products correctly.
	 *
	 * @param array $args
	 * @param WP_Query $wp_query
	 * @return array
	 */
	public static function posts_clauses($args, $wp_query) {
		// Remove this filter, to prevent it from running again for other queries that don't
		// need to be altered
		//remove_filter('posts_clauses', [__CLASS__, 'posts_clauses'], 999, 2);

		if(!static::should_sort_query_by_currency_prices() || !$wp_query->is_main_query()) {
			return $args;
		}

		global $wpdb;

		if(!strstr($args['join'], 'product_currency_price_sorting')) {
			$active_currency = get_woocommerce_currency();
			$price_meta_key = (self::$_sort_order === 'DESC') ? '_aelia_max_price' : '_aelia_min_price';

			$args['join'] .= trim("
				LEFT JOIN {$wpdb->postmeta} AS product_currency_price_sorting ON
					(product_currency_price_sorting.post_id = {$wpdb->posts}.ID) AND
					(product_currency_price_sorting.meta_key = '{$price_meta_key}_{$active_currency}')
			");

			$args['orderby'] = implode(",\n", [
				"CAST(product_currency_price_sorting.meta_value AS DECIMAL(10,6)) " . static::$_sort_order,
				"product_currency_price_sorting.post_id ASC",
			]);
		}

		return $args;
	}

	/**
	 * Intercepts the price of a product in the active currency and stores it in the database, so
	 * that it can be used for sorting.
	 *
	 * @param float|string $price
	 * @param WC_Product $product
	 * @return float|string
	 */
	public static function woocommerce_product_get_price($price, $product) {
		static $saved_product_prices = [];
		$active_currency = get_woocommerce_currency();

		if(($saved_product_prices[$product->get_id()] ?? '') !== $price) {
			// Find the callback to use for the product type
			$product_price_update_callback = static::get_price_update_callback($product);

			if(is_callable($product_price_update_callback)) {
				call_user_func($product_price_update_callback, $product, $price, $active_currency);
			}

			$saved_product_prices[$product->get_id()] = $price;
		}

		return $price;
	}

	/**
	 * Returns the callback that should be used to update the product price in the active currency.
	 *
	 * @param WC_Product $product
	 * @return callable
	 */
	protected static function get_price_update_callback(WC_Product $product): callable {
		$callbacks = [
			'variable' => [__CLASS__, 'update_variable_product_currency_prices'],
			'default' => [__CLASS__, 'update_product_currency_prices'],
		];

		$callback = apply_filters('wc_aelia_cps_product_price_update_callback', $callbacks[$product->get_type()] ?? $callbacks['default'], $product);

		// Handle condition in which the filter returns an invalid callback
		if(!is_callable($callback)) {
			trigger_error(__('Filter "wc_aelia_cps_product_price_update_callback" did not return a valid callback. Using default callback.', self::TEXT_DOMAIN), E_USER_WARNING);
			$callback = $callbacks[$product->get_type()] ?? $callbacks['default'];
		}

		return $callback;
	}

	/**
	 * Updates the currency specific prices for a generic product (e.g. simple, external, grouped).
	 *
	 * @param WC_Product $product
	 * @param float|string $product_price
	 * @param string $currency
	 * @return void
	 */
	protected static function update_product_currency_prices(WC_Product $product, $product_price, string $currency): void {
		$product->update_meta_data("_aelia_min_price_{$currency}", $product_price);
		$product->update_meta_data("_aelia_max_price_{$currency}", $product_price);
		$product->save_meta_data();
	}

	/**
	 * Updates the currency specific prices for a variable product.
	 *
	 * @param WC_Product_Variable $product
	 * @param float $product_price
	 * @param string $currency
	 * @return void
	 */
	protected static function update_variable_product_currency_prices(WC_Product_Variable $product, $product_price, string $currency): void {
		global $wpdb;

		// Load the list of prices of all product's variations
		// @see WC_Product_Variable_Data_Store_CPT::sync_price()
		$children = $product->get_visible_children();
		if(!empty($children)) {
			$format = array_fill( 0, count( $children ), '%d');
			$query_in = '(' . implode( ',', $format ) . ')';
			$prices = array_unique( $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM $wpdb->postmeta WHERE meta_key = '_price' AND post_id IN {$query_in}", $children ) ) ); // @codingStandardsIgnoreLine.
			sort($prices, SORT_NUMERIC);
		} else {
			$prices = [];
		}

		$product->update_meta_data("_aelia_min_price_{$currency}", reset($prices));
		$product->update_meta_data("_aelia_max_price_{$currency}", end($prices));
		$product->save_meta_data();
	}

	/**
	 * Performs the activation operations.
	 *
	 * @return void
	 */
	public static function activate(): void {
		register_uninstall_hook(__FILE__, [__CLASS__, 'uninstall']);
	}

	/**
	 * Performs the uninstallation operations.
	 *
	 * @return void
	 */
	public static function uninstall(): void {
		global $wpdb;

		$SQL = trim("
			DELETE FROM {$wpdb->postmeta}
			WHERE
				(meta_key like '_aelia_min_price%') OR
				(meta_key like '_aelia_max_price%')
		");
		$wpdb->query($SQL);
	}
}

WC_Aelia_Currency_Price_Sorting::init();
register_activation_hook(__FILE__, [WC_Aelia_Currency_Price_Sorting::class, 'activate']);


4. Support

The code and the instructions in this article are provided as is, without implicit or explicit guarantees, and fall outside the scope of our support service. We recommend to involve your developers in the installation and configuration of the code. If you, or your developers, have any technical question, they can contact us, and we will get back to you as soon as possible.