From c15b7546341e208197cfa092cad148466f899db6 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Wed, 15 Jul 2026 22:55:57 +0600 Subject: [PATCH 1/2] fix(woocommerce): subtract tax from price filter bounds The Filter by Price widget compared including-tax bounds against the excluding-tax price indexed in Elasticsearch, so products were dropped. Mirror WooCommerce core by subtracting inclusive tax from min/max bounds when prices are entered excluding tax but the shop shows including tax. Fixes #4332 --- .../classes/Feature/WooCommerce/Products.php | 47 +++++++ .../WooCommerce/TestWooCommerceProduct.php | 116 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/includes/classes/Feature/WooCommerce/Products.php b/includes/classes/Feature/WooCommerce/Products.php index 0ecafad66..62a6f3e72 100644 --- a/includes/classes/Feature/WooCommerce/Products.php +++ b/includes/classes/Feature/WooCommerce/Products.php @@ -118,6 +118,15 @@ public function price_filter( $args, $query_args, $query ) { $max_price = ! empty( $_GET['max_price'] ) ? sanitize_text_field( wp_unslash( $_GET['max_price'] ) ) : null; // phpcs:enable WordPress.Security.NonceVerification + // Align bounds with the excluding-tax price Elasticsearch indexes when the + // shop shows including-tax prices, matching WooCommerce core. + if ( null !== $min_price ) { + $min_price = $this->get_price_filter_tax_adjustment( (float) $min_price ); + } + if ( null !== $max_price ) { + $max_price = $this->get_price_filter_tax_adjustment( (float) $max_price ); + } + if ( $query->is_search() ) { /** * This logic is iffy but the WC price filter widget is not intended for use with search anyway @@ -154,6 +163,44 @@ public function price_filter( $args, $query_args, $query ) { return $args; } + /** + * Subtract inclusive tax from a price filter value so it matches the + * excluding-tax price Elasticsearch stores. + * + * Mirrors WooCommerce core (WC_Query::price_filter_post_clauses). Only kicks + * in when prices are entered without tax but the shop shows including-tax + * prices, the case where the Filter by Price widget sends including-tax bounds. + * + * @param float $price Raw bound from min_price or max_price. + * @return float + */ + protected function get_price_filter_tax_adjustment( $price ) { + if ( ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() ) { + return $price; + } + + if ( 'incl' !== get_option( 'woocommerce_tax_display_shop' ) ) { + return $price; + } + + if ( function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax() ) { + return $price; + } + + if ( ! method_exists( 'WC_Tax', 'get_rates' ) ) { + return $price; + } + + $tax_class = apply_filters( 'woocommerce_price_filter_widget_tax_class', '' ); // Standard tax class. + $tax_rates = \WC_Tax::get_rates( $tax_class ); + + if ( empty( $tax_rates ) ) { + return $price; + } + + return $price - \WC_Tax::get_tax_total( \WC_Tax::calc_inclusive_tax( $price, $tax_rates ) ); + } + /** * Index WooCommerce products meta fields * diff --git a/tests/php/features/WooCommerce/TestWooCommerceProduct.php b/tests/php/features/WooCommerce/TestWooCommerceProduct.php index ea6a7fdb3..09574067b 100644 --- a/tests/php/features/WooCommerce/TestWooCommerceProduct.php +++ b/tests/php/features/WooCommerce/TestWooCommerceProduct.php @@ -695,6 +695,122 @@ public function testPriceFilterWithSearchQuery() { $this->assertEquals( 2, count( $query ) ); } + /** + * Test the price filter subtracts inclusive tax from bounds so they match + * the excluding-tax price indexed by Elasticsearch. + * + * Reproduces issue #4332: prices entered without tax, shop displays including + * tax. The Filter by Price widget sends including-tax bounds, which must be + * reduced to the excluding-tax value before the Elasticsearch range query. + * + * @group woocommerce + * @group woocommerce-products + */ + public function testPriceFilterWithTax() { + global $wpdb, $wp_the_query, $wp_query; + + ElasticPress\Features::factory()->activate_feature( 'woocommerce' ); + ElasticPress\Features::factory()->setup_features(); + + // Capture existing values so we can restore them even if assertions fail. + $option_keys = array( + 'woocommerce_calc_taxes', + 'woocommerce_tax_display_shop', + 'woocommerce_prices_include_tax', + 'woocommerce_default_country', + ); + $old_options = array(); + foreach ( $option_keys as $option_key ) { + $old_options[ $option_key ] = get_option( $option_key ); + } + + // Prices entered without tax, shop displays including tax. + update_option( 'woocommerce_calc_taxes', 'yes' ); + update_option( 'woocommerce_tax_display_shop', 'incl' ); + update_option( 'woocommerce_prices_include_tax', 'no' ); + update_option( 'woocommerce_default_country', 'GB' ); + + // Seed a 20% tax rate for the base location so WC_Tax::get_rates finds it. + $wpdb->insert( + $wpdb->prefix . 'woocommerce_tax_rates', + array( + 'tax_rate_country' => 'GB', + 'tax_rate_state' => '', + 'tax_rate' => '20', + 'tax_rate_name' => 'VAT', + 'tax_rate_priority' => 1, + 'tax_rate_compound' => 0, + 'tax_rate_shipping' => 1, + 'tax_rate_order' => 1, + 'tax_rate_class' => '', + ) + ); + $tax_rate_id = $wpdb->insert_id; + \WC_Cache_Helper::invalidate_cache_group( 'taxes' ); + + try { + $this->ep_factory->product->create( + [ + 'name' => 'Cap 1', + 'regular_price' => 100, + ] + ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + // Widget bound is the including-tax price (120), stored price is 100. + parse_str( 'min_price=120&max_price=120', $_GET ); + + $args = array( + 'post_type' => 'product', + ); + $query = new \WP_Query( $args ); + + // mock the query as main query and is_search + $wp_the_query = $query; + $wp_query->is_search = true; + + add_filter( + 'ep_post_formatted_args', + function ( $formatted_args ) { + + $expected_result = array( + 'range' => array( + 'meta._price.long' => array( + 'gte' => 100.0, + 'lte' => 100.0, + 'boost' => 2, + ), + ), + ); + + $this->assertEquals( $expected_result, $formatted_args['query'] ); + return $formatted_args; + }, + 15 + ); + + $query = $query->query( $args ); + + $this->assertTrue( $wp_the_query->elasticsearch_success, 'Elasticsearch query failed' ); + $this->assertEquals( 1, count( $query ) ); + } finally { + // Restore options and remove the seeded tax rate regardless of outcome. + $wpdb->delete( $wpdb->prefix . 'woocommerce_tax_rates', array( 'tax_rate_id' => $tax_rate_id ) ); + \WC_Cache_Helper::invalidate_cache_group( 'taxes' ); + + foreach ( $old_options as $option_key => $option_value ) { + if ( false === $option_value ) { + delete_option( $option_key ); + } else { + update_option( $option_key, $option_value ); + } + } + + unset( $_GET['min_price'], $_GET['max_price'] ); + } + } + /** * Tests that attributes filter uses Elasticsearch. * From 44f4ae52af9cb1a18c156067e44c8f023c84d832 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Tue, 28 Jul 2026 16:32:16 +0600 Subject: [PATCH 2/2] fix(woocommerce): use decimal field in price filter range query The price filter range queries targeted meta._price.long, which stores prices as whole numbers via intval(). Decimal prices like 100.99 lost their decimal portion, and tax-adjusted bounds became fractional numbers that no longer matched the truncated stored value, excluding products from results. Switch the range query to meta._price.double, which is already indexed alongside long by prepare_meta_value_types() and used by InstantResults. No reindex required, no mapping change. The test now seeds a decimal product price (100.99) and an incl-tax bound of 121.188 to reproduce the rounding bug directly. Also update testPriceFilterWithoutTax's assertion, which was still expecting meta._price.long. Addresses PR #4338 feedback. Fixes #4332 --- .../classes/Feature/WooCommerce/Products.php | 16 ++++++++-------- .../WooCommerce/TestWooCommerceProduct.php | 18 +++++++++++------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/includes/classes/Feature/WooCommerce/Products.php b/includes/classes/Feature/WooCommerce/Products.php index 62a6f3e72..059b415e8 100644 --- a/includes/classes/Feature/WooCommerce/Products.php +++ b/includes/classes/Feature/WooCommerce/Products.php @@ -135,29 +135,29 @@ public function price_filter( $args, $query_args, $query ) { unset( $args['query']['bool']['should'] ); if ( ! empty( $min_price ) ) { - $args['query']['bool']['must'][0]['range']['meta._price.long']['gte'] = $min_price; + $args['query']['bool']['must'][0]['range']['meta._price.double']['gte'] = $min_price; } if ( ! empty( $max_price ) ) { - $args['query']['bool']['must'][0]['range']['meta._price.long']['lte'] = $max_price; + $args['query']['bool']['must'][0]['range']['meta._price.double']['lte'] = $max_price; } - $args['query']['bool']['must'][0]['range']['meta._price.long']['boost'] = 2.0; - $args['query']['bool']['must'][1]['bool'] = $old_query; + $args['query']['bool']['must'][0]['range']['meta._price.double']['boost'] = 2.0; + $args['query']['bool']['must'][1]['bool'] = $old_query; } else { unset( $args['query']['match_all'] ); - $args['query']['range']['meta._price.long']['gte'] = ! empty( $min_price ) ? $min_price : 0; + $args['query']['range']['meta._price.double']['gte'] = ! empty( $min_price ) ? $min_price : 0; if ( ! empty( $min_price ) ) { - $args['query']['range']['meta._price.long']['gte'] = $min_price; + $args['query']['range']['meta._price.double']['gte'] = $min_price; } if ( ! empty( $max_price ) ) { - $args['query']['range']['meta._price.long']['lte'] = $max_price; + $args['query']['range']['meta._price.double']['lte'] = $max_price; } - $args['query']['range']['meta._price.long']['boost'] = 2.0; + $args['query']['range']['meta._price.double']['boost'] = 2.0; } return $args; diff --git a/tests/php/features/WooCommerce/TestWooCommerceProduct.php b/tests/php/features/WooCommerce/TestWooCommerceProduct.php index 09574067b..ce975382d 100644 --- a/tests/php/features/WooCommerce/TestWooCommerceProduct.php +++ b/tests/php/features/WooCommerce/TestWooCommerceProduct.php @@ -615,7 +615,7 @@ function ( $formatted_args ) { $expected_result = array( 'range' => array( - 'meta._price.long' => array( + 'meta._price.double' => array( 'gte' => 1, 'lte' => 999, 'boost' => 2, @@ -752,14 +752,18 @@ public function testPriceFilterWithTax() { $this->ep_factory->product->create( [ 'name' => 'Cap 1', - 'regular_price' => 100, + 'regular_price' => 100.99, ] ); ElasticPress\Elasticsearch::factory()->refresh_indices(); - // Widget bound is the including-tax price (120), stored price is 100. - parse_str( 'min_price=120&max_price=120', $_GET ); + // Decimal price (100.99) exposes the rounding bug fixed by switching + // from meta._price.long (intval-truncated) to meta._price.double. + // WC math for 100.99 @ 20% tax: inclusivetax = 20.198, so the + // incl-tax price WC displays is 121.188 — sending that bound + // adjusts back to 100.99 and matches the indexed double value. + parse_str( 'min_price=121.188&max_price=121.188', $_GET ); $args = array( 'post_type' => 'product', @@ -776,9 +780,9 @@ function ( $formatted_args ) { $expected_result = array( 'range' => array( - 'meta._price.long' => array( - 'gte' => 100.0, - 'lte' => 100.0, + 'meta._price.double' => array( + 'gte' => 100.99, + 'lte' => 100.99, 'boost' => 2, ), ),