@@ -615,7 +615,7 @@ function ( $formatted_args ) {
615615
616616 $ expected_result = array (
617617 'range ' => array (
618- 'meta._price.long ' => array (
618+ 'meta._price.double ' => array (
619619 'gte ' => 1 ,
620620 'lte ' => 999 ,
621621 'boost ' => 2 ,
@@ -695,6 +695,135 @@ public function testPriceFilterWithSearchQuery() {
695695 $ this ->assertEquals ( 2 , count ( $ query ) );
696696 }
697697
698+ /**
699+ * Test the price filter subtracts inclusive tax from bounds so they match
700+ * the excluding-tax price indexed by Elasticsearch.
701+ *
702+ * Reproduces issue #4332: prices entered without tax, shop displays including
703+ * tax. The Filter by Price widget sends including-tax bounds, which must be
704+ * reduced to the excluding-tax value before the Elasticsearch range query.
705+ *
706+ * @since 5.3.4
707+ * @group woocommerce
708+ * @group woocommerce-products
709+ */
710+ public function testPriceFilterWithTax () {
711+ global $ wpdb , $ wp_the_query , $ wp_query ;
712+
713+ ElasticPress \Features::factory ()->activate_feature ( 'woocommerce ' );
714+ ElasticPress \Features::factory ()->setup_features ();
715+
716+ // Capture existing values so we can restore them even if assertions fail.
717+ $ option_keys = array (
718+ 'woocommerce_calc_taxes ' ,
719+ 'woocommerce_tax_display_shop ' ,
720+ 'woocommerce_prices_include_tax ' ,
721+ 'woocommerce_default_country ' ,
722+ 'woocommerce_tax_based_on ' ,
723+ );
724+ $ old_options = array ();
725+ foreach ( $ option_keys as $ option_key ) {
726+ $ old_options [ $ option_key ] = get_option ( $ option_key );
727+ }
728+
729+ // Prices entered without tax, shop displays including tax.
730+ update_option ( 'woocommerce_calc_taxes ' , 'yes ' );
731+ update_option ( 'woocommerce_tax_display_shop ' , 'incl ' );
732+ update_option ( 'woocommerce_prices_include_tax ' , 'no ' );
733+ update_option ( 'woocommerce_default_country ' , 'GB ' );
734+
735+ // Force tax lookup against the shop base, not the customer's address.
736+ // Without this, a leftover session or a `woocommerce_tax_based_on`
737+ // setting from a prior test can make WC_Customer::get_taxable_address()
738+ // return a non-GB tuple, which returns [] from WC_Tax::get_rates('')
739+ // and skips the tax adjustment.
740+ update_option ( 'woocommerce_tax_based_on ' , 'base ' );
741+
742+ // Seed a 20% tax rate for the base location so WC_Tax::get_rates finds it.
743+ $ wpdb ->insert (
744+ $ wpdb ->prefix . 'woocommerce_tax_rates ' ,
745+ array (
746+ 'tax_rate_country ' => 'GB ' ,
747+ 'tax_rate_state ' => '' ,
748+ 'tax_rate ' => '20 ' ,
749+ 'tax_rate_name ' => 'VAT ' ,
750+ 'tax_rate_priority ' => 1 ,
751+ 'tax_rate_compound ' => 0 ,
752+ 'tax_rate_shipping ' => 1 ,
753+ 'tax_rate_order ' => 1 ,
754+ 'tax_rate_class ' => '' ,
755+ )
756+ );
757+ $ tax_rate_id = $ wpdb ->insert_id ;
758+ \WC_Cache_Helper::invalidate_cache_group ( 'taxes ' );
759+
760+ try {
761+ $ this ->ep_factory ->product ->create (
762+ [
763+ 'name ' => 'Cap 1 ' ,
764+ 'regular_price ' => 100.99 ,
765+ ]
766+ );
767+
768+ ElasticPress \Elasticsearch::factory ()->refresh_indices ();
769+
770+ // Decimal price (100.99) exposes the rounding bug fixed by switching
771+ // from meta._price.long (intval-truncated) to meta._price.double.
772+ // WC math for 100.99 @ 20% tax: inclusivetax = 20.198, so the
773+ // incl-tax price WC displays is 121.188 — sending that bound
774+ // adjusts back to 100.99 and matches the indexed double value.
775+ parse_str ( 'min_price=121.188&max_price=121.188 ' , $ _GET );
776+
777+ $ args = array (
778+ 'post_type ' => 'product ' ,
779+ );
780+ $ query = new \WP_Query ( $ args );
781+
782+ // mock the query as main query and is_search
783+ $ wp_the_query = $ query ;
784+ $ wp_query ->is_search = true ;
785+
786+ add_filter (
787+ 'ep_post_formatted_args ' ,
788+ function ( $ formatted_args ) {
789+
790+ $ expected_result = array (
791+ 'range ' => array (
792+ 'meta._price.double ' => array (
793+ 'gte ' => 100.99 ,
794+ 'lte ' => 100.99 ,
795+ 'boost ' => 2 ,
796+ ),
797+ ),
798+ );
799+
800+ $ this ->assertEquals ( $ expected_result , $ formatted_args ['query ' ] );
801+ return $ formatted_args ;
802+ },
803+ 15
804+ );
805+
806+ $ query = $ query ->query ( $ args );
807+
808+ $ this ->assertTrue ( $ wp_the_query ->elasticsearch_success , 'Elasticsearch query failed ' );
809+ $ this ->assertEquals ( 1 , count ( $ query ) );
810+ } finally {
811+ // Restore options and remove the seeded tax rate regardless of outcome.
812+ $ wpdb ->delete ( $ wpdb ->prefix . 'woocommerce_tax_rates ' , array ( 'tax_rate_id ' => $ tax_rate_id ) );
813+ \WC_Cache_Helper::invalidate_cache_group ( 'taxes ' );
814+
815+ foreach ( $ old_options as $ option_key => $ option_value ) {
816+ if ( false === $ option_value ) {
817+ delete_option ( $ option_key );
818+ } else {
819+ update_option ( $ option_key , $ option_value );
820+ }
821+ }
822+
823+ unset( $ _GET ['min_price ' ], $ _GET ['max_price ' ] );
824+ }
825+ }
826+
698827 /**
699828 * Tests that attributes filter uses Elasticsearch.
700829 *
0 commit comments