Skip to content

Commit 1512d21

Browse files
authored
[WOOTAX-313] Refactor - Migrate the TaxJar rate-table seam onto the Address value object (#2988)
* Add - Characterization tests for the TaxJar rate-table seam. Golden-masters the `wp_woocommerce_tax_rates` round trip before `create_or_update_tax_rate()` and `allow_street_address_for_matched_rates()` are migrated onto the address value object. Duplicate rate rows are the failure mode this seam produces, and they are silent — the right rate is still charged, so nothing surfaces until the table has grown a row per checkout. Every case therefore calls twice and asserts on the row delta. Measuring the current behaviour corrected two of the three asymmetries the refactor plan attributes to this seam: - The postcode asymmetry (written through `wc_normalize_postcode()`, read raw) is real but unreachable: `WC_Tax::find_rates()` applies the identical `wc_normalize_postcode( wc_clean( … ) )` to its own argument, so core reconciles it. ZIP+4 round-trips today. - The state asymmetry is the reverse of what the plan describes. Core's `prepare_tax_rate()` pushes `tax_rate_state` through `sanitize_key()`, so the column holds a *more* normalised value than either reader asks for. `'N Y'` round-trips only because `str_replace( ' ', '' )` coincides with `sanitize_key()` for a space; `'N.Y.'` inserts a fresh row every time. Three defects are pinned as-is rather than fixed here, so that the fix appears as a deliberate change to this file: - a state carrying anything outside `[a-z0-9_-]` duplicates rows on every calculation; - a city carrying anything `sanitize_text_field()` removes does the same, because the write applies `wc_clean()` and the lookup does not; - `allow_street_address_for_matched_rates()` normalises the state not at all, so it cannot see rows that `create_or_update_tax_rate()` can — the `woocommerce_matched_rates` path silently returns no rates for those addresses while itemized cart totals stay correct. Also pins the parts that must survive untouched: ordinal-position matching of priorities, in-place rate updates, and the VAT branch writing no location rows. * Refactor - Migrate the TaxJar rate-table seam onto the Address value object. `create_or_update_tax_rate()` and `allow_street_address_for_matched_rates()` now derive their `find_rates()` arguments and their `location_code` rows from one `Address`, through a matched pair of projections the value object owns — `to_find_rates_args()` and `to_rate_table_locations()`. Deriving them separately is what let this seam look up values it had never written and insert a fresh `wp_woocommerce_tax_rates` row for the same address on every calculation. Three defects the characterization tests pinned are fixed: - A state carrying anything outside `[a-z0-9_-]` duplicated a rate row per calculation. `WC_Tax::prepare_tax_rate()` singles `tax_rate_state` out for `sanitize_key()` before storing it, and the lookup only stripped spaces — an approximation that happened to be right for `'N Y'` and wrong for `'N.Y.'` and for anything non-ASCII. `Address::state_compact()` now mirrors the core function instead of approximating it, so the lookup asks for the value core actually stored. - A city carrying anything `sanitize_text_field()` removes did the same: the write applied `wc_clean()` and the lookup did not. Sanitisation now happens once, inside the shared projection. - `allow_street_address_for_matched_rates()` normalised the city but not the state, so it could not see rows `create_or_update_tax_rate()` had just written. Rate rows existed and the `woocommerce_matched_rates` path — price display, shipping tax, coupons — returned nothing for those addresses, while itemized cart totals stayed correct. Two of the plan's three target asymmetries turned out not to be live, and the tests say so rather than the code pretending to fix them: `find_rates()` applies the same `wc_normalize_postcode( wc_clean( … ) )` to its argument that the write applies, so ZIP+4 always round-tripped; and core upper-cases the city on both ends, so the plugin's asymmetric `strtoupper()` was unobservable. Behaviour deltas beyond the fixes above: - A comma-list postcode is now stored as its first segment rather than whole. The row is keyed by the segment the rate was actually quoted for — the TaxJar request body has carried only that segment since the request seam moved onto the value object. - `get_backend_address()` no longer upper-cases postcode, city and street. That was this path's own historic behaviour, unmatched by `get_address()`, and kept only because the city reaches the rate-table city column — which is now upper-cased at both the write and the lookup. The admin recalculate and the cart now put identically-cased values on the wire. This changes the request JSON, so one generation of the `md5( $json )` request cache is invalidated. - `tax_rate_country` is trimmed before storage, so a whitespace-padded country can no longer produce a row nothing matches. - `create_or_update_tax_rate()` tolerates a missing or non-scalar location field instead of emitting a notice or storing the literal "Array". It is public and takes the location it is handed. `WC_Connect_TaxJar_Integration::normalize_city()` now raises its deprecation notice. It could not before: the last three in-repo callers were all on this seam, and the notice would have fired on every checkout. The method itself stays — it is `protected static`, so an out-of-repo subclass may call it. * Tweak - Add changelog entries for the TaxJar rate-table seam fixes. * Add - Pin the sanitize_key() collapse cases in state_compact(). Requested independently by both reviewers on the rate-table seam PR: the changelog cites a non-Latin state as the motivating case, but no provider row exercised one, so the behavior rested on reasoning rather than on a test. Adds five rows. Three collapse to empty outright (Cyrillic, CJK, a bare diacritic). Two collapse only partially -- 'IF' with a circumflex becomes 'F', 'AL' with a ring above becomes 'L' -- and those are the dangerous shape, because the result is a shorter but still perfectly plausible state code. That is exactly the duplication path this seam exists to close: the row is stored under the collapsed value and looked up under the raw one, so it can never be found again and a fresh row is inserted on every calculation. Expected values were read off WordPress's own sanitize_key() rather than derived by hand; the test's existing second assertion re-checks that agreement per row. All five fail against the pre-migration str_replace() implementation. * Tweak - Stamp the normalize_city() runtime notice with 3.6.12, not 3.6.11. 3.6.11 shipped on 2026-08-05 while this stack was open, so the version passed to wc_deprecated_function() names a release that went out without the deprecation in it. This PR's changelog entries land in the unreleased 3.6.12 block, which is the release that will actually raise the notice. The audience is the reason this matters. normalize_city() is protected static and retained solely so an out-of-repo subclass does not break on load; the notice is the only signal those consumers get, and it tells them which release to check their migration against. Pointing at 3.6.11 sends them to a release where nothing changed. Matches the @deprecated tag, corrected on the base branch when it was added. Tests are unaffected: setExpectedDeprecated() matches on the function name, not the version string. * Tweak - Narrow the rate-row growth claim to the states that actually duplicated. The changelog entry named three triggers — a period, a space, and a non-Latin character. Measured against core, only one of the three is right. WC_Tax::prepare_tax_rate() stores tax_rate_state as strtoupper( sanitize_key() ), and WC_Tax::get_matched_tax_rates() matches it with `tax_rate_state IN ( %s, '' )`. So a row is unfindable — and duplicated on every calculation — only when the stored value is non-empty *and* differs from what the lookup asked for. A space never diverged: the old str_replace( ' ', '' ) lookup happened to agree with sanitize_key() there, and only there. A state that collapses to '' outright never diverged either, because the blank satisfies the second arm of the IN clause whatever was asked for. What actually duplicated is a state that collapses *partially*, to a shorter non-empty code: 'N.Y.' -> 'NY', 'ÎF' -> 'F'. Rewords the entry to key on the mechanism rather than on scripts, and applies the same correction to readme.txt, which carries the published verbatim copy. The state_compact() docblock carried the same overbroad claim and gains the boundary. * Add - Pin the sanitize_key() collapse boundary at the rate-table seam. state_compact_provider() already pins what the *projection* returns for a collapsing state. Nothing pinned what that collapse does to the rate table, which is the claim the changelog entry actually makes — and the reason the entry was able to drift. Adds two rows to provide_rate_table_round_trips(), which feeds both the round-trip test and the matched-rates lookup test, so two rows are four cases: - 'state partially collapsing' (RO / 'ÎF', stored as 'F') is a regression test. Against the pre-migration space-stripping lookup both its cases fail: the round trip inserts a second row, and allow_street_address_for_matched_rates() cannot see the row create_or_update_tax_rate() had just written. - 'state wholly non-Latin' (RU / 'ЛЕН', stored as '') is a boundary characterization and passes with or without the migration. The blank matches the '' arm of core's `tax_rate_state IN ( %s, '' )`, so this shape never duplicated. It also pins that such an address yields a country-wide rate. Together they are the narrowing: the same "non-Latin character" trigger, opposite outcomes, decided by whether anything survives the stripping. Both cities are plain ASCII on purpose, so the state dimension is not confounded with the separate byte-parity contract to_find_rates_args() keeps with core.
1 parent b01e71f commit 1512d21

6 files changed

Lines changed: 952 additions & 65 deletions

File tree

changelog.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
* Fix - Prevent tax calculation from failing when the store address postcode holds more than one comma-separated value.
1010
* Fix - Accept lower-case country and state codes from the woocommerce_taxjar_nexus_address filter.
1111
* Fix - Fall back to the store address when a custom nexus address is incomplete instead of abandoning the calculation.
12+
* Fix - Prevent unbounded growth of WooCommerce tax rate rows when the customer state contains a character WooCommerce strips before storing it, such as a period or an accented letter.
13+
* Fix - Restore tax rates on the price display, shipping tax and coupon paths for addresses whose state was stored in a normalized form.
1214
* Tweak - Centralize TaxJar address handling in an internal value object. No change to tax calculation.
15+
* Tweak - Store tax rate rows against the postcode the rate was quoted for when the address postcode holds more than one comma-separated value.
1316

1417
= 3.6.11 - 2026-08-05 =
1518
* Fix - Prevent a fatal error during cart and checkout tax calculation when TaxJar returns an incomplete tax response.

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

Lines changed: 70 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -709,27 +709,32 @@ protected function get_address( $location_type = null ) {
709709
/**
710710
* Allow street address to be passed when finding rates
711711
*
712-
* @param array $matched_tax_rates
713-
* @param string $tax_class
712+
* Despite the name, no street ever reaches `WC_Tax::find_rates()` — it takes no
713+
* street argument, and the value this method used to unpack from the tuple was
714+
* never read. What the callback actually does is accept a location of *four or
715+
* more* elements where `WC_Tax::get_rates_from_location()` accepts exactly four,
716+
* so it supplies the rates core skips when this plugin's five-element taxable
717+
* address is in play. It is public and hooked on `woocommerce_matched_rates`,
718+
* which core fires from the price-display, shipping-tax and coupon paths.
719+
*
720+
* The lookup arguments now come from the same value object `create_or_update_tax_rate()`
721+
* writes with. Before, this method normalised the city but not the state, so it
722+
* could not see rows that method had just written: the rate rows existed and
723+
* these paths returned nothing for them.
724+
*
725+
* @param array $matched_tax_rates Rates core matched; replaced wholesale.
726+
* @param string $tax_class Tax class slug.
714727
* @return array
715728
*/
716729
public function allow_street_address_for_matched_rates( $matched_tax_rates, $tax_class = '' ) {
717-
$tax_class = sanitize_title( $tax_class );
718-
$location = WC_Tax::get_tax_location( $tax_class );
719-
$matched_tax_rates = array();
720-
if ( sizeof( $location ) >= 4 ) {
721-
list( $country, $state, $postcode, $city, $street ) = array_pad( $location, 5, '' );
722-
$matched_tax_rates = WC_Tax::find_rates(
723-
array(
724-
'country' => $country,
725-
'state' => $state,
726-
'postcode' => $postcode,
727-
'city' => strtoupper( self::normalize_city( $city ) ),
728-
'tax_class' => $tax_class,
729-
)
730-
);
730+
$tax_class = sanitize_title( $tax_class );
731+
$location = WC_Tax::get_tax_location( $tax_class );
732+
733+
if ( ! is_array( $location ) || count( $location ) < 4 ) {
734+
return array();
731735
}
732-
return $matched_tax_rates;
736+
737+
return WC_Tax::find_rates( Address::from_taxable_tuple( $location )->to_find_rates_args( $tax_class ) );
733738
}
734739

735740
public function cleanup_tax_label( $rate_name ) {
@@ -935,27 +940,17 @@ public function get_taxable_address( $location_type = null ) {
935940
* Security: WooCommerce has already verified the nonce and capability by the time
936941
* `woocommerce_before_save_order_items` fires.
937942
*
943+
* This method used to `strtoupper()` postcode, city and street on top of that,
944+
* which `get_address()` did not — the same order recalculated from the admin and
945+
* from the cart put differently-cased values on the wire. The casing was kept only
946+
* because the city feeds the `wp_woocommerce_tax_rates` city column; now that the
947+
* rate-table seam upper-cases the city at both the write and the lookup, nothing
948+
* downstream depends on it and the two paths agree.
949+
*
938950
* @return array
939951
*/
940952
protected function get_backend_address() {
941-
$address = Address::from_post_request()->to_legacy_options();
942-
943-
/*
944-
* Historic behaviour of this method, preserved deliberately: the admin path
945-
* upper-cases every field, not only country and state (which `Address` already
946-
* normalises). It is load-bearing for the city, which reaches the
947-
* `wp_woocommerce_tax_rates` city column, and pinned by
948-
* `test_get_backend_address_normalizes_semicolon_city()`. Reconciling the casing
949-
* asymmetry between this path and `get_address()` belongs with the rate-table
950-
* seam, where both ends of the write/read round trip can move together.
951-
*/
952-
foreach ( array( 'to_zip', 'to_city', 'to_street' ) as $key ) {
953-
if ( is_string( $address[ $key ] ) ) {
954-
$address[ $key ] = strtoupper( $address[ $key ] );
955-
}
956-
}
957-
958-
return $address;
953+
return Address::from_post_request()->to_legacy_options();
959954
}
960955

961956
/**
@@ -1859,9 +1854,11 @@ private function get_itemized_tax_rates( $taxes, $taxjar_taxes, $options ): arra
18591854
* normalisation policy has one home rather than one copy per consumer. This
18601855
* method is retained as a delegate — it is `protected static`, so a subclass
18611856
* outside this repository may be calling it, and removing it would break that
1862-
* subclass on load. The remaining in-repo call sites move to the value object
1863-
* when their seams are migrated; no `_deprecated_function()` notice is raised
1864-
* because those call sites are still live and would fire it on every checkout.
1857+
* subclass on load.
1858+
*
1859+
* The `_deprecated_function()` notice can be raised as of this release because
1860+
* the last in-repo caller has moved to the value object. Until then the notice
1861+
* would have fired on every checkout.
18651862
*
18661863
* The non-string guard is preserved: this method has always returned its
18671864
* argument untouched when handed a non-string, and callers may rely on that.
@@ -1870,6 +1867,8 @@ private function get_itemized_tax_rates( $taxes, $taxjar_taxes, $options ): arra
18701867
* @return string Normalized city, safe for `_update_tax_rate_cities` and `find_rates`.
18711868
*/
18721869
protected static function normalize_city( $city ) {
1870+
wc_deprecated_function( __METHOD__, '3.6.12', '\Automattic\WCServices\Tax\Address::normalize_city()' );
1871+
18731872
if ( ! is_string( $city ) ) {
18741873
return $city;
18751874
}
@@ -1890,11 +1889,34 @@ protected static function normalize_city( $city ) {
18901889
* @return int
18911890
*/
18921891
public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $freight_taxable = 1, $rate_priority = 1, $tax_rate_name = 'Tax' ) {
1893-
// Prevent filling "State code" column for countries with VAT tax.
1894-
// VAT tax is country wide.
1895-
$to_state = 'VAT' === $tax_rate_name ? '' : strtoupper( $location['to_state'] );
18961892
$rate_priority = absint( $rate_priority );
18971893

1894+
/*
1895+
* One address, two consumers: the `find_rates()` lookup below and the location
1896+
* rows written beside the rate. Deriving them separately is what let this
1897+
* method insert a fresh row on every calculation for some addresses — it
1898+
* looked up values it had never stored. The value object now owns both
1899+
* projections, so they cannot drift apart.
1900+
*
1901+
* Non-scalars collapse to an empty string rather than reaching `(string)` and
1902+
* becoming the literal "Array". This method is public and takes the location
1903+
* it is handed.
1904+
*/
1905+
$field = static function ( $value ) {
1906+
return is_scalar( $value ) ? (string) $value : '';
1907+
};
1908+
1909+
$address = Address::from_options(
1910+
array(
1911+
'to_country' => $field( $location['to_country'] ?? '' ),
1912+
// Prevent filling "State code" column for countries with VAT tax.
1913+
// VAT tax is country wide.
1914+
'to_state' => 'VAT' === $tax_rate_name ? '' : $field( $location['to_state'] ?? '' ),
1915+
'to_zip' => $field( $location['to_zip'] ?? '' ),
1916+
'to_city' => $field( $location['to_city'] ?? '' ),
1917+
)
1918+
);
1919+
18981920
/**
18991921
* @see https://github.com/Automattic/woocommerce-services/issues/2531
19001922
* @see https://floridarevenue.com/faq/Pages/FAQDetails.aspx?FAQID=1277&IsDlg=1
@@ -1918,8 +1940,8 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
19181940
}
19191941

19201942
$tax_rate = array(
1921-
'tax_rate_country' => $location['to_country'],
1922-
'tax_rate_state' => $to_state,
1943+
'tax_rate_country' => $address->country(),
1944+
'tax_rate_state' => $address->state_compact(),
19231945
// For the US, we're going to modify the name of the tax rate to simplify the reporting and distinguish between the tax rates at the counties level.
19241946
// I would love to do this for other locations, but it looks like that would create issues.
19251947
// For example, for the UK it would continuously rename the rate name with an updated `state` "piece", each time a request is made
@@ -1931,15 +1953,7 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
19311953
'tax_rate_class' => $tax_class,
19321954
);
19331955

1934-
$wc_rates = WC_Tax::find_rates(
1935-
array(
1936-
'country' => $location['to_country'],
1937-
'state' => str_replace( ' ', '', $to_state ),
1938-
'postcode' => $location['to_zip'],
1939-
'city' => strtoupper( self::normalize_city( $location['to_city'] ) ),
1940-
'tax_class' => $tax_class,
1941-
)
1942-
);
1956+
$wc_rates = WC_Tax::find_rates( $address->to_find_rates_args( $tax_class ) );
19431957

19441958
$wc_rates_ids = is_array( $wc_rates ) ? array_keys( $wc_rates ) : array();
19451959
if ( isset( $wc_rates_ids[ $rate_priority - 1 ] ) ) {
@@ -1970,8 +1984,10 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
19701984
$rate_id = WC_Tax::_insert_tax_rate( $tax_rate );
19711985
// VAT is always country wide, no need to create separate entires for each zip and city.
19721986
if ( 'VAT' !== $tax_rate_name ) {
1973-
WC_Tax::_update_tax_rate_postcodes( $rate_id, wc_normalize_postcode( wc_clean( $location['to_zip'] ) ) );
1974-
WC_Tax::_update_tax_rate_cities( $rate_id, self::normalize_city( wc_clean( $location['to_city'] ) ) );
1987+
$locations = $address->to_rate_table_locations();
1988+
1989+
WC_Tax::_update_tax_rate_postcodes( $rate_id, $locations['postcode'] );
1990+
WC_Tax::_update_tax_rate_cities( $rate_id, $locations['city'] );
19751991
}
19761992
}
19771993

readme.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ This plugin relies on the following external services:
7979
* Fix - Prevent tax calculation from failing when the store address postcode holds more than one comma-separated value.
8080
* Fix - Accept lower-case country and state codes from the woocommerce_taxjar_nexus_address filter.
8181
* Fix - Fall back to the store address when a custom nexus address is incomplete instead of abandoning the calculation.
82+
* Fix - Prevent unbounded growth of WooCommerce tax rate rows when the customer state contains a character WooCommerce strips before storing it, such as a period or an accented letter.
83+
* Fix - Restore tax rates on the price display, shipping tax and coupon paths for addresses whose state was stored in a normalized form.
8284
* Tweak - Centralize TaxJar address handling in an internal value object. No change to tax calculation.
85+
* Tweak - Store tax rate rows against the postcode the rate was quoted for when the address postcode holds more than one comma-separated value.
8386

8487
= 3.6.11 - 2026-08-05 =
8588
* Fix - Prevent a fatal error during cart and checkout tax calculation when TaxJar returns an incomplete tax response.

src/Tax/Address.php

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,14 +352,34 @@ public function state(): string {
352352
}
353353

354354
/**
355-
* State with all spaces removed. Used by `WC_Tax::find_rates()` lookup —
356-
* state codes shouldn't contain spaces, and stripping defends against
357-
* accidental whitespace from form input.
355+
* State in the exact form WooCommerce core stores it in `tax_rate_state`.
356+
*
357+
* This is not a normalisation policy of ours — it is a mirror of core's.
358+
* `WC_Tax::prepare_tax_rate()` singles this one column out and runs it through
359+
* `sanitize_key()` before `format_tax_rate_state()` uppercases it, so the value
360+
* that reaches the column has been lower-cased and stripped of everything
361+
* outside `[a-z0-9_-]`. `WC_Tax::get_matched_tax_rates()` then compares against
362+
* it with a plain `tax_rate_state IN ( %s, '' )` and no normalisation of its own.
363+
*
364+
* A lookup that asks for anything else therefore cannot find the row it just
365+
* wrote, and `create_or_update_tax_rate()` responds by inserting another one —
366+
* once per calculation, forever. Stripping spaces (which is all this method used
367+
* to do) covers `'N Y'` and nothing else: `'N.Y.'`, and any state carrying
368+
* non-ASCII, still diverge.
369+
*
370+
* One boundary to that story: a state `sanitize_key()` empties *outright* (`'ЛЕН'`,
371+
* `'东京'`) was never the duplicating shape, because the blank it stores satisfies
372+
* the `''` arm of that `IN` clause whatever the lookup asked for. The rows that
373+
* could not be found again are the ones that collapse *partially*, to a shorter
374+
* non-empty code — `'ÎF'` stored as `'F'`.
375+
*
376+
* Because core applies the same function on the way in, mirroring it here cannot
377+
* lose information the stored value still has.
358378
*
359379
* @return string
360380
*/
361381
public function state_compact(): string {
362-
return str_replace( ' ', '', $this->state );
382+
return strtoupper( sanitize_key( $this->state ) );
363383
}
364384

365385
/**
@@ -601,8 +621,11 @@ public function to_nexus_array(): array {
601621
}
602622

603623
/**
604-
* Args for `WC_Tax::find_rates()`. State is space-compacted, city is uppercase
605-
* (consistent with WC core's `format_tax_rate_city()`), `tax_class` passed through.
624+
* Args for `WC_Tax::find_rates()` — the read end of the tax-rate-table round trip.
625+
*
626+
* Every field is in the form WC core stores it, so this and
627+
* `to_rate_table_locations()` cannot disagree: a row written from one address is
628+
* found by a lookup built from the same address.
606629
*
607630
* @param string $tax_class Tax class slug to look up.
608631
* @return array{country: string, state: string, postcode: string, city: string, tax_class: string}
@@ -611,12 +634,60 @@ public function to_find_rates_args( string $tax_class = '' ): array {
611634
return array(
612635
'country' => $this->country,
613636
'state' => $this->state_compact(),
614-
'postcode' => $this->postcode,
615-
'city' => strtoupper( $this->city ),
637+
'postcode' => $this->postcode_as_stored(),
638+
'city' => $this->city_as_stored(),
616639
'tax_class' => $tax_class,
617640
);
618641
}
619642

643+
/**
644+
* Location codes for the write end: the `postcode` and `city` rows that
645+
* `WC_Tax::_update_tax_rate_postcodes()` / `_update_tax_rate_cities()` persist
646+
* alongside a rate.
647+
*
648+
* Paired with `to_find_rates_args()` on purpose. Deriving the two independently is
649+
* what let `create_or_update_tax_rate()` look up a value it had never written and
650+
* insert a fresh row for the same address on every calculation.
651+
*
652+
* @return array{postcode: string, city: string}
653+
*/
654+
public function to_rate_table_locations(): array {
655+
return array(
656+
'postcode' => $this->postcode_as_stored(),
657+
'city' => $this->city_as_stored(),
658+
);
659+
}
660+
661+
/**
662+
* Postcode in the form WC core matches rate rows against.
663+
*
664+
* `WC_Tax::find_rates()` normalizes its argument with
665+
* `wc_normalize_postcode( wc_clean( … ) )`, while
666+
* `_update_tax_rate_postcodes()` stores what it is handed verbatim. Applying the
667+
* same normalisation to both ends is what closes that gap; it is idempotent, so
668+
* passing an already-normalised value into `find_rates()` changes nothing.
669+
*
670+
* @return string
671+
*/
672+
private function postcode_as_stored(): string {
673+
return (string) wc_normalize_postcode( wc_clean( $this->postcode ) );
674+
}
675+
676+
/**
677+
* City in the form WC core stores in the `city` location row.
678+
*
679+
* Core upper-cases and trims on both ends (`format_tax_rate_city()` on the write,
680+
* `strtoupper()` inside the lookup SQL) but sanitizes on neither. The plugin used
681+
* to apply `wc_clean()` on the write only, so a city carrying anything
682+
* `sanitize_text_field()` strips was stored in one form and searched for in
683+
* another — a fresh rate row per calculation.
684+
*
685+
* @return string
686+
*/
687+
private function city_as_stored(): string {
688+
return strtoupper( trim( (string) wc_clean( $this->city ) ) );
689+
}
690+
620691
/**
621692
* Back-compat shape used by `get_address()` and `get_backend_address()` return
622693
* values: `to_country`, `to_state`, `to_zip`, `to_city`, `to_street` — but with

0 commit comments

Comments
 (0)