[WOOTAX-19] Fix - Prevent duplicate tax rate rows for semicolon cities#2970
[WOOTAX-19] Fix - Prevent duplicate tax rate rows for semicolon cities#2970bartech wants to merge 2 commits into
Conversation
…ntains a semicolon.
WooCommerce core treats `;` in city values asymmetrically:
`WC_Tax::_update_tax_rate_cities()` `explode(';', ...)`s the input as a
multi-city separator, but `WC_Tax::find_rates()` queries the city as a
single literal `location_code = '<CITY>'`. So a checkout city like
`Casse;Berry` is stored as two location rows (`CASSE`, `BERRY`) yet
looked up as `CASSE;BERRY` — `find_rates()` always misses, and
`create_or_update_tax_rate()` inserts a fresh `wp_woocommerce_tax_rates`
row on every calculation.
Add a `normalize_city()` helper that strips `;` and collapses whitespace,
applied at the cart/checkout entry, the admin order POST entry, the
`find_rates()` lookup, and the `_update_tax_rate_cities()` storage call —
restoring round-trip symmetry. Includes a data-provider test for the
helper plus a DB round-trip regression test asserting two consecutive
calls with `Casse;Berry` reuse the same `tax_rate_id`.
There was a problem hiding this comment.
🤖 Automated AI pre-flight review — generated by Claude Code (
ultra-reviewskill) on behalf of @Abdalsalaam.
This is NOT an approval and not a substitute for human review; it is an early automated pass to surface likely issues. Treat every finding as a suggestion to verify.
Findings
Blockers (must fix before merge)
None.
Majors (should fix before merge)
None.
Minors (nice to fix)
M1 · classes/class-wc-connect-taxjar-integration.php:1791 · correctness/i18n — The whitespace-collapse regex lacks the Unicode flag: preg_replace( '/\s+/', ' ', $city ). Without /u, \s matches only ASCII whitespace, so multibyte whitespace (NBSP U+00A0, ideographic space U+3000) introduced around a stripped ; won't collapse — a stored value could then differ from the looked-up one. Low impact (the ;→space str_replace is the real fix; ASCII whitespace already collapses).
Suggested fix: preg_replace( '/\s+/u', ' ', $city ). But see M4-companion: adding /u makes preg_replace able to return null on malformed UTF-8, so pair it with a null-safe return — return trim( (string) $city ); (or $city = preg_replace(...) ?? ''). As written today (no /u, short bounded input) the trim(null) path is effectively unreachable; it becomes real only if /u is added without the guard.
M2 · classes/class-wc-connect-taxjar-integration.php:703 · consistency — allow_street_address_for_matched_rates() performs the same find_rates() city lookup the PR is hardening ('city' => strtoupper( $city ), where $city comes from WC_Tax::get_tax_location() / the customer session) but was not updated with normalize_city(). For a ;-bearing customer city this secondary lookup stays asymmetric with the now-normalized stored rows. This does not reintroduce the unbounded-growth bug (it only reads, never inserts), so it's a consistency gap against the PR's "restore round-trip symmetry" goal rather than a regression.
Suggested fix: 'city' => strtoupper( self::normalize_city( $city ) ) for parity with :1855.
M3 · tests/php/test-class-wc-connect-taxjar-integration.php · coverage — The core path is well covered, but: the four production call sites are only exercised via create_or_update_tax_rate; get_address (:673) and get_backend_address (:920, which wraps strtoupper(wc_clean())) have no direct assertion, and the outbound TaxJar-API city mutation is untested — a regression that dropped normalize_city() from get_backend_address would not be caught. The ! is_string() early-return branch (reachable in production when city is false) and tab/newline whitespace are absent from the data provider.
Suggested fix: add a focused test invoking get_backend_address() with $_POST['city'] = 'Casse;Berry' asserting no ;; add null/false/tab/newline cases to the provider.
M4 · classes/class-wc-connect-taxjar-integration.php:1790 · silent failure — A city consisting only of separators (e.g. ";", "; ;") normalizes to ''. Such a value passes the ! empty() guards at :673/:920 before normalization, so a city the caller treated as present is silently discarded (rate row created/looked up with no city component) with no log entry. Garbage input, but undebuggable when it happens.
Suggested fix: _log() when normalization empties a previously non-empty city.
Nits (optional)
- N1 ·
tests/php/…:regression-test cleanup — The DB regression test inserts realwoocommerce_tax_rates+_locationsrows and never deletes them; it relies onWC_Unit_Test_Case's per-test transaction rollback. Robust in practice (theCOUNT(*)is snapshotted before the calls, so pre-existing/leaked rows don't skew it), but not self-contained. Consider explicit cleanup of$first_id. - N2 ·
tests/php/…:190· phpcs comment —phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPreparedsits on a query whose values are properly passed through$wpdb->prepare(); only the prefix-derived table name is interpolated (no injection risk). The ignore is mislabeled vs the genuineCOUNT(*)cases at :176/:185. Optionally use the%iidentifier placeholder (WP 6.2+). - N3 · architecture — Three of the four normalization sites are redundant for the bug itself (the lookup+store pair suffices). This is intentional defense-in-depth and acceptable; noting it so reviewers know the breadth is deliberate, not accidental.
Follow-ups (candidates — none required to merge)
- Apply M1 (
/\s+/u+ null-safetrim) and M2 (normalize the :703 sibling lookup) for full symmetry. - Add the M3 entry-point + non-string + tab/newline test cases.
- (Optional) One-time cleanup script for stores already carrying duplicate
;-city tax-rate rows.
Address PR review feedback on the semicolon-city tax-rate fix: - normalize_city(): add the /u flag to the whitespace-collapse regex so multibyte whitespace (NBSP, ideographic space) introduced around a stripped `;` also collapses, and cast the preg_replace result before trim() since /u lets it return null on malformed UTF-8. - allow_street_address_for_matched_rates(): route its find_rates() city lookup through normalize_city() too, so a `;`-bearing customer city stays symmetric with the normalized stored rows on this sibling path. - Tests: add a direct get_backend_address() entry-point test for the admin recalculation path, plus null/false (non-string early return) and tab/newline cases to the normalize_city data provider.
CezaryDrewniak
left a comment
There was a problem hiding this comment.
LGTM with minor suggestions. Verified the fix is correct and well-scoped.
The fix: Adds normalize_city() (strips ; and collapses whitespace) and applies it at every entry point that touches the tax-rate tables or TaxJar API — get_address(), get_backend_address(), allow_street_address_for_matched_rates(), and both arms of create_or_update_tax_rate(). The defensive double-normalization inside create_or_update_tax_rate() is the right call, it makes the storage/lookup contract local to that method.
Tests: The three new tests are well-designed. test_create_or_update_tax_rate_does_not_duplicate_rows_for_semicolon_city is a real DB round-trip regression test that snapshots the row count, asserts idempotency, and verifies the stored location_code is semicolon-free. The tab and newline collapse to space and null/false cases in the data provider are good defensive coverage.
Lint: No new PHPCS errors. (composer run check-all reports 1423 pre-existing errors in the codebase; the new normalize_city() and the five call sites are clean. The test file has 0 errors.)
Non-blockers to consider:
get_backend_address()normalizes afterstrtoupper(line 920); the find_rates call at line 1856 normalizes before. Functionally identical (sincenormalize_cityonly touches;and whitespace), but worth aligning for consistency.- No direct test for the frontend
get_address()path with a;city — only the helper and backend path are exercised. test_create_or_update_tax_rate_does_not_duplicate_rows_for_semicolon_citydoesn't clean up the inserted tax rate intear_down— one row will leak per test run. Minor
Abdalsalaam
left a comment
There was a problem hiding this comment.
LGTM! I tested it and it work as expected.
Description
WooCommerce core treats a
;in a city value asymmetrically between storage and lookup:WC_Tax::_update_tax_rate_cities()explode(';', ...)s the input, treating;as a multi-city separator — so a city likeCasse;Berryis stored as two location rows (CASSEandBERRY).WC_Tax::find_rates()queries the city column as a single literal (location_code = 'CASSE;BERRY').Because of this mismatch,
find_rates()never matches the stored rows, socreate_or_update_tax_rate()believes no rate exists and inserts a freshwp_woocommerce_tax_ratesrow on every tax calculation — unbounded table growth for any store whose checkout city contains a semicolon (typo'd or pasted addresses).This PR adds a
normalize_city()helper that strips;and collapses the resulting whitespace, applied at every point that feeds the tax-rate tables or the TaxJar API:find_rates()lookup increate_or_update_tax_rate(),_update_tax_rate_cities()storage call.This restores round-trip symmetry: the value stored is the value looked up, so existing rows are reused instead of duplicated.
Related issue(s)
Closes WOOTAX-19
Steps to reproduce & screenshots/GIFs
Before this fix:
Casse;Berry.wp_woocommerce_tax_rates— each recalculation adds a new duplicate row, and tax lookups keep missing the existing rate.After this fix:
Casse Berry; the rate row is created once and reused on every subsequent calculation — no duplicate rows.Automated coverage (run
composer test):test_normalize_city_strips_semicolons_and_normalizes_whitespace— data-provider unit test for the helper (semicolons, leading/trailing/consecutive separators, whitespace).test_create_or_update_tax_rate_does_not_duplicate_rows_for_semicolon_city— DB round-trip regression test asserting two consecutive calls withCasse;Berryreuse the sametax_rate_idand add exactly one row.Checklist
changelog.txtentry addedreadme.txtentry added