Skip to content

Commit 1ca143c

Browse files
authored
Merge branch 'trunk' into add/wootax-306-agents-bc-guardrail
2 parents 22444b8 + bbf9cde commit 1ca143c

7 files changed

Lines changed: 155 additions & 11 deletions

changelog.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
*** WooCommerce Tax Changelog ***
22

3-
= 3.6.8 - 2026-xx-xx =
3+
= 3.6.8 - 2026-07-15 =
44
* Fix - TaxJar tax lines wiped when REST API order update includes address change.
55
* Fix - Prevent fatal error on sites running WooCommerce versions without StoreApi support.
6+
* Fix - Prevent unbounded growth of WooCommerce tax rate rows when checkout city contains a semicolon.
67

78
= 3.6.7 - 2026-07-06 =
89
* Fix - Prevent fatal error on Atomic sites caused by incorrect path resolution when loading the API client class.

classes/class-wc-connect-taxjar-integration.php

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,7 @@ protected function get_address( $location_type = null ) {
683683
$to_country = isset( $taxable_address[0] ) && ! empty( $taxable_address[0] ) ? strtoupper( $taxable_address[0] ) : false;
684684
$to_state = isset( $taxable_address[1] ) && ! empty( $taxable_address[1] ) ? strtoupper( $taxable_address[1] ) : false;
685685
$to_zip = isset( $taxable_address[2] ) && ! empty( $taxable_address[2] ) ? $taxable_address[2] : false;
686-
$to_city = isset( $taxable_address[3] ) && ! empty( $taxable_address[3] ) ? $taxable_address[3] : false;
686+
$to_city = isset( $taxable_address[3] ) && ! empty( $taxable_address[3] ) ? self::normalize_city( $taxable_address[3] ) : false;
687687
$to_street = isset( $taxable_address[4] ) && ! empty( $taxable_address[4] ) ? $taxable_address[4] : false;
688688

689689
return array(
@@ -713,7 +713,7 @@ public function allow_street_address_for_matched_rates( $matched_tax_rates, $tax
713713
'country' => $country,
714714
'state' => $state,
715715
'postcode' => $postcode,
716-
'city' => strtoupper( $city ),
716+
'city' => strtoupper( self::normalize_city( $city ) ),
717717
'tax_class' => $tax_class,
718718
)
719719
);
@@ -930,7 +930,7 @@ protected function get_backend_address() {
930930
$to_country = isset( $_POST['country'] ) ? strtoupper( wc_clean( $_POST['country'] ) ) : false;
931931
$to_state = isset( $_POST['state'] ) ? strtoupper( wc_clean( $_POST['state'] ) ) : false;
932932
$to_zip = isset( $_POST['postcode'] ) ? strtoupper( wc_clean( $_POST['postcode'] ) ) : false;
933-
$to_city = isset( $_POST['city'] ) ? strtoupper( wc_clean( $_POST['city'] ) ) : false;
933+
$to_city = isset( $_POST['city'] ) ? self::normalize_city( strtoupper( wc_clean( $_POST['city'] ) ) ) : false;
934934
$to_street = isset( $_POST['street'] ) ? strtoupper( wc_clean( $_POST['street'] ) ) : false;
935935
// phpcs:enable WordPress.Security.NonceVerification.Missing
936936

@@ -1777,6 +1777,36 @@ private function get_itemized_tax_rates( $taxes, $taxjar_taxes, $options ): arra
17771777
return $taxes;
17781778
}
17791779

1780+
/**
1781+
* Normalize a city value for safe round-trips through WooCommerce's tax rate tables.
1782+
*
1783+
* `WC_Tax::_update_tax_rate_cities()` treats `;` as a multi-city separator (it
1784+
* `explode(';', ...)`s the input), but `WC_Tax::find_rates()` queries the city
1785+
* column with a single `location_code = '<CITY>'` literal — so a checkout city
1786+
* containing `;` (e.g. typo'd `Casse;Berry`) gets stored as two separate location
1787+
* rows (`CASSE`, `BERRY`) yet looked up as the joined string `CASSE;BERRY`.
1788+
* That asymmetry causes `find_rates()` to miss on every subsequent calculation,
1789+
* which makes `create_or_update_tax_rate()` insert a fresh row each checkout —
1790+
* unbounded growth of `wp_woocommerce_tax_rates`. See WOOTAX-19.
1791+
*
1792+
* Stripping `;` (and collapsing the resulting whitespace runs) before any path
1793+
* touches the tax-rate tables or the TaxJar API restores the round-trip.
1794+
*
1795+
* @param string $city Raw city value, possibly user-entered.
1796+
* @return string Normalized city, safe for `_update_tax_rate_cities` and `find_rates`.
1797+
*/
1798+
protected static function normalize_city( $city ) {
1799+
if ( ! is_string( $city ) || '' === $city ) {
1800+
return $city;
1801+
}
1802+
1803+
$city = str_replace( ';', ' ', $city );
1804+
$city = preg_replace( '/\s+/u', ' ', $city );
1805+
1806+
// `preg_replace` returns null on malformed UTF-8 with the /u flag; cast so trim() stays safe.
1807+
return trim( (string) $city );
1808+
}
1809+
17801810
/**
17811811
* Add or update WooCommerce tax rate.
17821812
*
@@ -1836,7 +1866,7 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
18361866
'country' => $location['to_country'],
18371867
'state' => str_replace( ' ', '', $to_state ),
18381868
'postcode' => $location['to_zip'],
1839-
'city' => strtoupper( $location['to_city'] ),
1869+
'city' => strtoupper( self::normalize_city( $location['to_city'] ) ),
18401870
'tax_class' => $tax_class,
18411871
)
18421872
);
@@ -1871,7 +1901,7 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
18711901
// VAT is always country wide, no need to create separate entires for each zip and city.
18721902
if ( 'VAT' !== $tax_rate_name ) {
18731903
WC_Tax::_update_tax_rate_postcodes( $rate_id, wc_normalize_postcode( wc_clean( $location['to_zip'] ) ) );
1874-
WC_Tax::_update_tax_rate_cities( $rate_id, wc_clean( $location['to_city'] ) );
1904+
WC_Tax::_update_tax_rate_cities( $rate_id, self::normalize_city( wc_clean( $location['to_city'] ) ) );
18751905
}
18761906
}
18771907

npm-shrinkwrap.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "woocommerce-services",
3-
"version": "3.6.7",
3+
"version": "3.6.8",
44
"scripts": {
55
"start": "cross-env NODE_ENV=development CALYPSO_CLIENT=true webpack-dev-server --hot --inline --watch --content-base dist --port 8085 --host 0.0.0.0",
66
"watch": "cross-env NODE_ENV=development CALYPSO_CLIENT=true webpack --watch",

readme.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Requires Plugins: woocommerce
77
Tested up to: 7.0
88
WC requires at least: 10.7
99
WC tested up to: 10.9
10-
Stable tag: 3.6.7
10+
Stable tag: 3.6.8
1111
License: GPLv2 or later
1212
License URI: http://www.gnu.org/licenses/gpl-2.0.html
1313

@@ -70,9 +70,10 @@ This plugin relies on the following external services:
7070

7171
== Changelog ==
7272

73-
= 3.6.8 - 2026-xx-xx =
73+
= 3.6.8 - 2026-07-15 =
7474
* Fix - TaxJar tax lines wiped when REST API order update includes address change.
7575
* Fix - Prevent fatal error on sites running WooCommerce versions without StoreApi support.
76+
* Fix - Prevent unbounded growth of WooCommerce tax rate rows when checkout city contains a semicolon.
7677

7778
= 3.6.7 - 2026-07-06 =
7879
* Fix - Prevent fatal error on Atomic sites caused by incorrect path resolution when loading the API client class.

tests/php/test-class-wc-connect-taxjar-integration.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1753,4 +1753,116 @@ public function test_zero_amount_response_persists_real_itemized_rates() {
17531753
// top-level rate. This is the core guard against the zeroing-out regression.
17541754
$this->assertEqualsWithDelta( 6.975, array_sum( $persisted ), 0.0001, 'Existing tax rate must not be zeroed out by a $0 calculation.' );
17551755
}
1756+
1757+
/**
1758+
* `normalize_city()` strips semicolons and collapses whitespace.
1759+
*
1760+
* `WC_Tax::_update_tax_rate_cities()` treats `;` as a multi-city separator,
1761+
* but `WC_Tax::find_rates()` treats it as a literal character. `normalize_city()`
1762+
* strips `;` (and collapses whitespace) so the round-trip stays symmetric.
1763+
*
1764+
* @see WOOTAX-19
1765+
*
1766+
* @dataProvider normalize_city_provider
1767+
*
1768+
* @param string $input Raw city value to normalize.
1769+
* @param string $expected Expected normalized output.
1770+
*/
1771+
public function test_normalize_city_strips_semicolons_and_normalizes_whitespace( $input, $expected ) {
1772+
$reflection = new ReflectionMethod( 'WC_Connect_TaxJar_Integration', 'normalize_city' );
1773+
$reflection->setAccessible( true );
1774+
1775+
$this->assertSame( $expected, $reflection->invoke( null, $input ) );
1776+
}
1777+
1778+
/**
1779+
* Data provider for `test_normalize_city_strips_semicolons_and_normalizes_whitespace`.
1780+
*
1781+
* @return array<string, array{0: mixed, 1: mixed}>
1782+
*/
1783+
public function normalize_city_provider() {
1784+
return array(
1785+
'no semicolon — unchanged' => array( 'New York', 'New York' ),
1786+
'simple semicolon between words' => array( 'Casse;Berry', 'Casse Berry' ),
1787+
'semicolon with following space' => array( 'Casse; Berry', 'Casse Berry' ),
1788+
'leading semicolon' => array( ';Casselberry', 'Casselberry' ),
1789+
'trailing semicolon' => array( 'Casselberry;', 'Casselberry' ),
1790+
'consecutive semicolons' => array( 'Casse;;Berry', 'Casse Berry' ),
1791+
'wrapped in whitespace' => array( ' Casselberry ', 'Casselberry' ),
1792+
'tab and newline collapse to space' => array( "Casse;\t\nBerry", 'Casse Berry' ),
1793+
'empty string' => array( '', '' ),
1794+
'multi-segment with mixed separators' => array( ' Casse; ;Berry ', 'Casse Berry' ),
1795+
'null — returned unchanged' => array( null, null ),
1796+
'false — returned unchanged' => array( false, false ),
1797+
);
1798+
}
1799+
1800+
/**
1801+
* `get_backend_address()` strips a semicolon from an admin order city.
1802+
*
1803+
* The admin "Recalculate" path builds its taxable address from `$_POST`, so a
1804+
* `;`-bearing city must be normalized there too — otherwise backend recalculations
1805+
* would reintroduce the stored/looked-up asymmetry the frontend path now avoids.
1806+
*
1807+
* @see WOOTAX-19
1808+
*/
1809+
public function test_get_backend_address_normalizes_semicolon_city() {
1810+
$_POST['country'] = 'US';
1811+
$_POST['state'] = 'FL';
1812+
$_POST['postcode'] = '33033';
1813+
$_POST['city'] = 'Casse;Berry';
1814+
1815+
try {
1816+
$address = $this->invoke_protected_method( 'get_backend_address' );
1817+
} finally {
1818+
unset( $_POST['country'], $_POST['state'], $_POST['postcode'], $_POST['city'] );
1819+
}
1820+
1821+
$this->assertStringNotContainsString( ';', $address['to_city'], 'Backend order city must not retain a semicolon — `_update_tax_rate_cities()` would split it.' );
1822+
$this->assertSame( 'CASSE BERRY', $address['to_city'] );
1823+
}
1824+
1825+
/**
1826+
* `create_or_update_tax_rate()` is idempotent across semicolon-bearing cities.
1827+
*
1828+
* Regression test for the unbounded `wp_woocommerce_tax_rates` growth:
1829+
* `create_or_update_tax_rate()` called twice with the same semicolon-bearing
1830+
* city must reuse the existing rate row instead of inserting a duplicate.
1831+
*
1832+
* @see WOOTAX-19
1833+
*/
1834+
public function test_create_or_update_tax_rate_does_not_duplicate_rows_for_semicolon_city() {
1835+
global $wpdb;
1836+
1837+
$location = array(
1838+
'to_country' => 'US',
1839+
'to_state' => 'FL',
1840+
'to_zip' => '33033',
1841+
'to_city' => 'Casse;Berry',
1842+
'from_state' => 'FL',
1843+
);
1844+
1845+
// Snapshot the row count BEFORE the first call so the test isn't sensitive
1846+
// to fixtures/seed data (test DB might already have rates from other tests).
1847+
$rates_table = $wpdb->prefix . 'woocommerce_tax_rates';
1848+
$initial_rate_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$rates_table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1849+
1850+
$first_id = $this->integration->create_or_update_tax_rate( $location, 0.07, '', 1, 1, 'Tax' );
1851+
$second_id = $this->integration->create_or_update_tax_rate( $location, 0.07, '', 1, 1, 'Tax' );
1852+
1853+
// Same row id on both calls — find_rates() matched the second time.
1854+
$this->assertSame( (int) $first_id, (int) $second_id, 'Second create_or_update_tax_rate() inserted a new row instead of reusing the existing one — find_rates() city lookup is asymmetric with _update_tax_rate_cities() storage.' );
1855+
1856+
// Exactly one new row added, not two.
1857+
$final_rate_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$rates_table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1858+
$this->assertSame( $initial_rate_count + 1, $final_rate_count, 'Expected exactly one new tax rate row after two create_or_update_tax_rate() calls with the same Casse;Berry city.' );
1859+
1860+
// Stored city in the locations table should be normalized — no `;`.
1861+
$locations_table = $wpdb->prefix . 'woocommerce_tax_rate_locations';
1862+
$stored_cities = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$locations_table} WHERE tax_rate_id = %d AND location_type = %s", (int) $first_id, 'city' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1863+
$this->assertNotEmpty( $stored_cities );
1864+
foreach ( $stored_cities as $city ) {
1865+
$this->assertStringNotContainsString( ';', $city, 'Tax rate city stored with a semicolon — `_update_tax_rate_cities()` will split it and break find_rates() on subsequent lookups.' );
1866+
}
1867+
}
17561868
}

woocommerce-services.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* Author URI: https://woocommerce.com/
99
* Text Domain: woocommerce-services
1010
* Domain Path: /i18n/languages/
11-
* Version: 3.6.7
11+
* Version: 3.6.8
1212
* Requires Plugins: woocommerce
1313
* Requires PHP: 7.4
1414
* Requires at least: 6.9

0 commit comments

Comments
 (0)