Skip to content

Commit 9001816

Browse files
authored
Merge pull request #4338 from faisalahammad/fix/4332-woo-price-filter-tax
fix(woocommerce): subtract tax from price filter bounds
2 parents ba7fea2 + 9046fe5 commit 9001816

2 files changed

Lines changed: 186 additions & 9 deletions

File tree

includes/classes/Feature/WooCommerce/Products.php

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ public function price_filter( $args, $query_args, $query ) {
118118
$max_price = ! empty( $_GET['max_price'] ) ? sanitize_text_field( wp_unslash( $_GET['max_price'] ) ) : null;
119119
// phpcs:enable WordPress.Security.NonceVerification
120120

121+
// Align bounds with the excluding-tax price Elasticsearch indexes when the
122+
// shop shows including-tax prices, matching WooCommerce core.
123+
if ( null !== $min_price ) {
124+
$min_price = $this->get_price_filter_tax_adjustment( (float) $min_price );
125+
}
126+
if ( null !== $max_price ) {
127+
$max_price = $this->get_price_filter_tax_adjustment( (float) $max_price );
128+
}
129+
121130
if ( $query->is_search() ) {
122131
/**
123132
* This logic is iffy but the WC price filter widget is not intended for use with search anyway
@@ -126,34 +135,73 @@ public function price_filter( $args, $query_args, $query ) {
126135
unset( $args['query']['bool']['should'] );
127136

128137
if ( ! empty( $min_price ) ) {
129-
$args['query']['bool']['must'][0]['range']['meta._price.long']['gte'] = $min_price;
138+
$args['query']['bool']['must'][0]['range']['meta._price.double']['gte'] = $min_price;
130139
}
131140

132141
if ( ! empty( $max_price ) ) {
133-
$args['query']['bool']['must'][0]['range']['meta._price.long']['lte'] = $max_price;
142+
$args['query']['bool']['must'][0]['range']['meta._price.double']['lte'] = $max_price;
134143
}
135144

136-
$args['query']['bool']['must'][0]['range']['meta._price.long']['boost'] = 2.0;
137-
$args['query']['bool']['must'][1]['bool'] = $old_query;
145+
$args['query']['bool']['must'][0]['range']['meta._price.double']['boost'] = 2.0;
146+
$args['query']['bool']['must'][1]['bool'] = $old_query;
138147
} else {
139148
unset( $args['query']['match_all'] );
140149

141-
$args['query']['range']['meta._price.long']['gte'] = ! empty( $min_price ) ? $min_price : 0;
150+
$args['query']['range']['meta._price.double']['gte'] = ! empty( $min_price ) ? $min_price : 0;
142151

143152
if ( ! empty( $min_price ) ) {
144-
$args['query']['range']['meta._price.long']['gte'] = $min_price;
153+
$args['query']['range']['meta._price.double']['gte'] = $min_price;
145154
}
146155

147156
if ( ! empty( $max_price ) ) {
148-
$args['query']['range']['meta._price.long']['lte'] = $max_price;
157+
$args['query']['range']['meta._price.double']['lte'] = $max_price;
149158
}
150159

151-
$args['query']['range']['meta._price.long']['boost'] = 2.0;
160+
$args['query']['range']['meta._price.double']['boost'] = 2.0;
152161
}
153162

154163
return $args;
155164
}
156165

166+
/**
167+
* Subtract inclusive tax from a price filter value so it matches the
168+
* excluding-tax price Elasticsearch stores.
169+
*
170+
* Mirrors WooCommerce core (WC_Query::price_filter_post_clauses). Only kicks
171+
* in when prices are entered without tax but the shop shows including-tax
172+
* prices, the case where the Filter by Price widget sends including-tax bounds.
173+
*
174+
* @since 5.3.4
175+
* @param float $price Raw bound from min_price or max_price.
176+
* @return float
177+
*/
178+
protected function get_price_filter_tax_adjustment( $price ) {
179+
if ( ! function_exists( 'wc_tax_enabled' ) || ! wc_tax_enabled() ) {
180+
return $price;
181+
}
182+
183+
if ( 'incl' !== get_option( 'woocommerce_tax_display_shop' ) ) {
184+
return $price;
185+
}
186+
187+
if ( function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax() ) {
188+
return $price;
189+
}
190+
191+
if ( ! method_exists( 'WC_Tax', 'get_rates' ) ) {
192+
return $price;
193+
}
194+
195+
$tax_class = apply_filters( 'woocommerce_price_filter_widget_tax_class', '' ); // Standard tax class.
196+
$tax_rates = \WC_Tax::get_rates( $tax_class );
197+
198+
if ( empty( $tax_rates ) ) {
199+
return $price;
200+
}
201+
202+
return $price - \WC_Tax::get_tax_total( \WC_Tax::calc_inclusive_tax( $price, $tax_rates ) );
203+
}
204+
157205
/**
158206
* Index WooCommerce products meta fields
159207
*

tests/php/features/WooCommerce/TestWooCommerceProduct.php

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)