Skip to content

Commit 4a0c966

Browse files
committed
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.
1 parent ad4ca52 commit 4a0c966

4 files changed

Lines changed: 371 additions & 95 deletions

File tree

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

Lines changed: 70 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -691,27 +691,32 @@ protected function get_address( $location_type = null ) {
691691
/**
692692
* Allow street address to be passed when finding rates
693693
*
694-
* @param array $matched_tax_rates
695-
* @param string $tax_class
694+
* Despite the name, no street ever reaches `WC_Tax::find_rates()` — it takes no
695+
* street argument, and the value this method used to unpack from the tuple was
696+
* never read. What the callback actually does is accept a location of *four or
697+
* more* elements where `WC_Tax::get_rates_from_location()` accepts exactly four,
698+
* so it supplies the rates core skips when this plugin's five-element taxable
699+
* address is in play. It is public and hooked on `woocommerce_matched_rates`,
700+
* which core fires from the price-display, shipping-tax and coupon paths.
701+
*
702+
* The lookup arguments now come from the same value object `create_or_update_tax_rate()`
703+
* writes with. Before, this method normalised the city but not the state, so it
704+
* could not see rows that method had just written: the rate rows existed and
705+
* these paths returned nothing for them.
706+
*
707+
* @param array $matched_tax_rates Rates core matched; replaced wholesale.
708+
* @param string $tax_class Tax class slug.
696709
* @return array
697710
*/
698711
public function allow_street_address_for_matched_rates( $matched_tax_rates, $tax_class = '' ) {
699-
$tax_class = sanitize_title( $tax_class );
700-
$location = WC_Tax::get_tax_location( $tax_class );
701-
$matched_tax_rates = array();
702-
if ( sizeof( $location ) >= 4 ) {
703-
list( $country, $state, $postcode, $city, $street ) = array_pad( $location, 5, '' );
704-
$matched_tax_rates = WC_Tax::find_rates(
705-
array(
706-
'country' => $country,
707-
'state' => $state,
708-
'postcode' => $postcode,
709-
'city' => strtoupper( self::normalize_city( $city ) ),
710-
'tax_class' => $tax_class,
711-
)
712-
);
712+
$tax_class = sanitize_title( $tax_class );
713+
$location = WC_Tax::get_tax_location( $tax_class );
714+
715+
if ( ! is_array( $location ) || count( $location ) < 4 ) {
716+
return array();
713717
}
714-
return $matched_tax_rates;
718+
719+
return WC_Tax::find_rates( Address::from_taxable_tuple( $location )->to_find_rates_args( $tax_class ) );
715720
}
716721

717722
public function cleanup_tax_label( $rate_name ) {
@@ -917,27 +922,17 @@ public function get_taxable_address( $location_type = null ) {
917922
* Security: WooCommerce has already verified the nonce and capability by the time
918923
* `woocommerce_before_save_order_items` fires.
919924
*
925+
* This method used to `strtoupper()` postcode, city and street on top of that,
926+
* which `get_address()` did not — the same order recalculated from the admin and
927+
* from the cart put differently-cased values on the wire. The casing was kept only
928+
* because the city feeds the `wp_woocommerce_tax_rates` city column; now that the
929+
* rate-table seam upper-cases the city at both the write and the lookup, nothing
930+
* downstream depends on it and the two paths agree.
931+
*
920932
* @return array
921933
*/
922934
protected function get_backend_address() {
923-
$address = Address::from_post_request()->to_legacy_options();
924-
925-
/*
926-
* Historic behaviour of this method, preserved deliberately: the admin path
927-
* upper-cases every field, not only country and state (which `Address` already
928-
* normalises). It is load-bearing for the city, which reaches the
929-
* `wp_woocommerce_tax_rates` city column, and pinned by
930-
* `test_get_backend_address_normalizes_semicolon_city()`. Reconciling the casing
931-
* asymmetry between this path and `get_address()` belongs with the rate-table
932-
* seam, where both ends of the write/read round trip can move together.
933-
*/
934-
foreach ( array( 'to_zip', 'to_city', 'to_street' ) as $key ) {
935-
if ( is_string( $address[ $key ] ) ) {
936-
$address[ $key ] = strtoupper( $address[ $key ] );
937-
}
938-
}
939-
940-
return $address;
935+
return Address::from_post_request()->to_legacy_options();
941936
}
942937

943938
/**
@@ -1791,9 +1786,11 @@ private function get_itemized_tax_rates( $taxes, $taxjar_taxes, $options ): arra
17911786
* normalisation policy has one home rather than one copy per consumer. This
17921787
* method is retained as a delegate — it is `protected static`, so a subclass
17931788
* outside this repository may be calling it, and removing it would break that
1794-
* subclass on load. The remaining in-repo call sites move to the value object
1795-
* when their seams are migrated; no `_deprecated_function()` notice is raised
1796-
* because those call sites are still live and would fire it on every checkout.
1789+
* subclass on load.
1790+
*
1791+
* The `_deprecated_function()` notice can be raised as of this release because
1792+
* the last in-repo caller has moved to the value object. Until then the notice
1793+
* would have fired on every checkout.
17971794
*
17981795
* The non-string guard is preserved: this method has always returned its
17991796
* argument untouched when handed a non-string, and callers may rely on that.
@@ -1802,6 +1799,8 @@ private function get_itemized_tax_rates( $taxes, $taxjar_taxes, $options ): arra
18021799
* @return string Normalized city, safe for `_update_tax_rate_cities` and `find_rates`.
18031800
*/
18041801
protected static function normalize_city( $city ) {
1802+
wc_deprecated_function( __METHOD__, '3.6.11', '\Automattic\WCServices\Tax\Address::normalize_city()' );
1803+
18051804
if ( ! is_string( $city ) ) {
18061805
return $city;
18071806
}
@@ -1822,11 +1821,34 @@ protected static function normalize_city( $city ) {
18221821
* @return int
18231822
*/
18241823
public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $freight_taxable = 1, $rate_priority = 1, $tax_rate_name = 'Tax' ) {
1825-
// Prevent filling "State code" column for countries with VAT tax.
1826-
// VAT tax is country wide.
1827-
$to_state = 'VAT' === $tax_rate_name ? '' : strtoupper( $location['to_state'] );
18281824
$rate_priority = absint( $rate_priority );
18291825

1826+
/*
1827+
* One address, two consumers: the `find_rates()` lookup below and the location
1828+
* rows written beside the rate. Deriving them separately is what let this
1829+
* method insert a fresh row on every calculation for some addresses — it
1830+
* looked up values it had never stored. The value object now owns both
1831+
* projections, so they cannot drift apart.
1832+
*
1833+
* Non-scalars collapse to an empty string rather than reaching `(string)` and
1834+
* becoming the literal "Array". This method is public and takes the location
1835+
* it is handed.
1836+
*/
1837+
$field = static function ( $value ) {
1838+
return is_scalar( $value ) ? (string) $value : '';
1839+
};
1840+
1841+
$address = Address::from_options(
1842+
array(
1843+
'to_country' => $field( $location['to_country'] ?? '' ),
1844+
// Prevent filling "State code" column for countries with VAT tax.
1845+
// VAT tax is country wide.
1846+
'to_state' => 'VAT' === $tax_rate_name ? '' : $field( $location['to_state'] ?? '' ),
1847+
'to_zip' => $field( $location['to_zip'] ?? '' ),
1848+
'to_city' => $field( $location['to_city'] ?? '' ),
1849+
)
1850+
);
1851+
18301852
/**
18311853
* @see https://github.com/Automattic/woocommerce-services/issues/2531
18321854
* @see https://floridarevenue.com/faq/Pages/FAQDetails.aspx?FAQID=1277&IsDlg=1
@@ -1850,8 +1872,8 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
18501872
}
18511873

18521874
$tax_rate = array(
1853-
'tax_rate_country' => $location['to_country'],
1854-
'tax_rate_state' => $to_state,
1875+
'tax_rate_country' => $address->country(),
1876+
'tax_rate_state' => $address->state_compact(),
18551877
// 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.
18561878
// I would love to do this for other locations, but it looks like that would create issues.
18571879
// For example, for the UK it would continuously rename the rate name with an updated `state` "piece", each time a request is made
@@ -1863,15 +1885,7 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
18631885
'tax_rate_class' => $tax_class,
18641886
);
18651887

1866-
$wc_rates = WC_Tax::find_rates(
1867-
array(
1868-
'country' => $location['to_country'],
1869-
'state' => str_replace( ' ', '', $to_state ),
1870-
'postcode' => $location['to_zip'],
1871-
'city' => strtoupper( self::normalize_city( $location['to_city'] ) ),
1872-
'tax_class' => $tax_class,
1873-
)
1874-
);
1888+
$wc_rates = WC_Tax::find_rates( $address->to_find_rates_args( $tax_class ) );
18751889

18761890
$wc_rates_ids = is_array( $wc_rates ) ? array_keys( $wc_rates ) : array();
18771891
if ( isset( $wc_rates_ids[ $rate_priority - 1 ] ) ) {
@@ -1902,8 +1916,10 @@ public function create_or_update_tax_rate( $location, $rate, $tax_class = '', $f
19021916
$rate_id = WC_Tax::_insert_tax_rate( $tax_rate );
19031917
// VAT is always country wide, no need to create separate entires for each zip and city.
19041918
if ( 'VAT' !== $tax_rate_name ) {
1905-
WC_Tax::_update_tax_rate_postcodes( $rate_id, wc_normalize_postcode( wc_clean( $location['to_zip'] ) ) );
1906-
WC_Tax::_update_tax_rate_cities( $rate_id, self::normalize_city( wc_clean( $location['to_city'] ) ) );
1919+
$locations = $address->to_rate_table_locations();
1920+
1921+
WC_Tax::_update_tax_rate_postcodes( $rate_id, $locations['postcode'] );
1922+
WC_Tax::_update_tax_rate_cities( $rate_id, $locations['city'] );
19071923
}
19081924
}
19091925

src/Tax/Address.php

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -344,14 +344,28 @@ public function state(): string {
344344
}
345345

346346
/**
347-
* State with all spaces removed. Used by `WC_Tax::find_rates()` lookup —
348-
* state codes shouldn't contain spaces, and stripping defends against
349-
* accidental whitespace from form input.
347+
* State in the exact form WooCommerce core stores it in `tax_rate_state`.
348+
*
349+
* This is not a normalisation policy of ours — it is a mirror of core's.
350+
* `WC_Tax::prepare_tax_rate()` singles this one column out and runs it through
351+
* `sanitize_key()` before `format_tax_rate_state()` uppercases it, so the value
352+
* that reaches the column has been lower-cased and stripped of everything
353+
* outside `[a-z0-9_-]`. `WC_Tax::get_matched_tax_rates()` then compares against
354+
* it with a plain `tax_rate_state IN ( %s, '' )` and no normalisation of its own.
355+
*
356+
* A lookup that asks for anything else therefore cannot find the row it just
357+
* wrote, and `create_or_update_tax_rate()` responds by inserting another one —
358+
* once per calculation, forever. Stripping spaces (which is all this method used
359+
* to do) covers `'N Y'` and nothing else: `'N.Y.'`, and any state carrying
360+
* non-ASCII, still diverge.
361+
*
362+
* Because core applies the same function on the way in, mirroring it here cannot
363+
* lose information the stored value still has.
350364
*
351365
* @return string
352366
*/
353367
public function state_compact(): string {
354-
return str_replace( ' ', '', $this->state );
368+
return strtoupper( sanitize_key( $this->state ) );
355369
}
356370

357371
/**
@@ -574,8 +588,11 @@ public function to_nexus_array(): array {
574588
}
575589

576590
/**
577-
* Args for `WC_Tax::find_rates()`. State is space-compacted, city is uppercase
578-
* (consistent with WC core's `format_tax_rate_city()`), `tax_class` passed through.
591+
* Args for `WC_Tax::find_rates()` — the read end of the tax-rate-table round trip.
592+
*
593+
* Every field is in the form WC core stores it, so this and
594+
* `to_rate_table_locations()` cannot disagree: a row written from one address is
595+
* found by a lookup built from the same address.
579596
*
580597
* @param string $tax_class Tax class slug to look up.
581598
* @return array{country: string, state: string, postcode: string, city: string, tax_class: string}
@@ -584,12 +601,60 @@ public function to_find_rates_args( string $tax_class = '' ): array {
584601
return array(
585602
'country' => $this->country,
586603
'state' => $this->state_compact(),
587-
'postcode' => $this->postcode,
588-
'city' => strtoupper( $this->city ),
604+
'postcode' => $this->postcode_as_stored(),
605+
'city' => $this->city_as_stored(),
589606
'tax_class' => $tax_class,
590607
);
591608
}
592609

610+
/**
611+
* Location codes for the write end: the `postcode` and `city` rows that
612+
* `WC_Tax::_update_tax_rate_postcodes()` / `_update_tax_rate_cities()` persist
613+
* alongside a rate.
614+
*
615+
* Paired with `to_find_rates_args()` on purpose. Deriving the two independently is
616+
* what let `create_or_update_tax_rate()` look up a value it had never written and
617+
* insert a fresh row for the same address on every calculation.
618+
*
619+
* @return array{postcode: string, city: string}
620+
*/
621+
public function to_rate_table_locations(): array {
622+
return array(
623+
'postcode' => $this->postcode_as_stored(),
624+
'city' => $this->city_as_stored(),
625+
);
626+
}
627+
628+
/**
629+
* Postcode in the form WC core matches rate rows against.
630+
*
631+
* `WC_Tax::find_rates()` normalizes its argument with
632+
* `wc_normalize_postcode( wc_clean( … ) )`, while
633+
* `_update_tax_rate_postcodes()` stores what it is handed verbatim. Applying the
634+
* same normalisation to both ends is what closes that gap; it is idempotent, so
635+
* passing an already-normalised value into `find_rates()` changes nothing.
636+
*
637+
* @return string
638+
*/
639+
private function postcode_as_stored(): string {
640+
return (string) wc_normalize_postcode( wc_clean( $this->postcode ) );
641+
}
642+
643+
/**
644+
* City in the form WC core stores in the `city` location row.
645+
*
646+
* Core upper-cases and trims on both ends (`format_tax_rate_city()` on the write,
647+
* `strtoupper()` inside the lookup SQL) but sanitizes on neither. The plugin used
648+
* to apply `wc_clean()` on the write only, so a city carrying anything
649+
* `sanitize_text_field()` strips was stored in one form and searched for in
650+
* another — a fresh rate row per calculation.
651+
*
652+
* @return string
653+
*/
654+
private function city_as_stored(): string {
655+
return strtoupper( trim( (string) wc_clean( $this->city ) ) );
656+
}
657+
593658
/**
594659
* Back-compat shape used by `get_address()` and `get_backend_address()` return
595660
* values: `to_country`, `to_state`, `to_zip`, `to_city`, `to_street` — but with

0 commit comments

Comments
 (0)