[WOOTAX-313] Refactor - Migrate the TaxJar rate-table seam onto the Address value object - #2988
Conversation
iyut
left a comment
There was a problem hiding this comment.
Approving. Nice piece of work Bart — the "one address, two projections" framing makes the write/read symmetry easy to follow, and shipping the characterization tests as a separate first commit made the behavior changes genuinely reviewable rather than something to reverse-engineer from the diff.
I checked state_compact(), the postcode/city round-trip, and the allow_street_address_for_matched_rates() early-return against WC core and they line up — the sanitize_key() mirroring holds on both ends, and the idempotency argument for the postcode/city normalization checks out. No regression for normal single-postcode addresses; the projections come out byte-identical to the old code there.
One small optional thing, not blocking: the changelog calls out a non-Latin state as a motivating case, but I don't see a provider row exercising a state that sanitize_key() collapses to ''. The behavior's correct, it just currently rests on reasoning rather than a test — might be worth one row to pin it. Happy to leave it for R2 if you'd rather.
The Florida-gate note in the description reads right to me too — agree that's an R2 concern.
CezaryDrewniak
left a comment
There was a problem hiding this comment.
Approving. The "one address, two projections" framing is the right abstraction and it makes the seam's symmetry obvious in a way the previous hand-rolled normalizations never were. Shipping the characterization tests as a separate first commit was the right call. The behavior changes read as deliberate edits to a known baseline rather than something you have to reverse-engineer from the diff.
I re-ran the projection logic against a normal US address end-to-end and the to_find_rates_args() / to_rate_table_locations() pair comes out byte-identical to what the old code produced for that case, so the seam change is invisible to the 95% path. The matched-rates fix is the one I'm most glad landed, silent disappearance of tax from price display / shipping / coupons is the kind of bug nobody files a ticket for.
Only thing I'd add, also non-blocking: the same sanitize_key() empty-collapse case the prior reviewer flagged would be nice to pin in state_compact_provider, but I agree it's R2.
Confirmed composer test locally: 331 tests, 793 assertions, all green.
c7e2477 to
5419d4f
Compare
798075f to
e807afb
Compare
Pushed — the
|
5419d4f to
8b8d662
Compare
e807afb to
662605d
Compare
Rebased onto trunk — changelog entries moved to 3.6.123.6.11 shipped while this stack was in review ( Fixed by rebasing the whole stack onto current trunk and moving every entry into a new No code changed in this push — changelog placement only, plus whatever trunk brought in (the WOOTAX-294 React 19 fix and the wpcs 3.4.1 bump). Verified after the rebase:
|
8b8d662 to
6765d40
Compare
662605d to
100c5e3
Compare
6765d40 to
2481bd8
Compare
100c5e3 to
2d076d0
Compare
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.
…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.
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.
…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.
…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.
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.
4b07593 to
91dbde4
Compare
Changes proposed in this Pull Request
Fourth and final PR of the R1 series (WOOTAX-313). Migrates the rate-table seam —
create_or_update_tax_rate()andallow_street_address_for_matched_rates()— ontoAutomattic\WCServices\Tax\Address, so the arguments used to find awp_woocommerce_tax_ratesrow and the values written into it come from one address through a matched pair of projections the value object owns:to_find_rates_args()andto_rate_table_locations().Stacked on #2986 (
chore/wootax-313-r1-3), which is stacked on #2985 → #2984. Review the base PRs first; this diff is only R1.4.This is the one PR in the series that can silently duplicate rate rows, so the characterization tests are a separate first commit (
ad4ca528) and the refactor lands under them (4a0c966e). The behaviour changes are visible as deliberate edits to that first commit's expectations.Measuring the seam corrected two of the three defects the plan attributed to it
The refactor plan lists three write/read asymmetries here. Two are real but not reachable, and this PR says so rather than shipping a fix for a bug that does not exist:
wc_normalize_postcode(), read rawWC_Tax::find_rates()applies the identicalwc_normalize_postcode( wc_clean( … ) )to its own argument before querying. ZIP+4 always round-tripped.format_tax_rate_city()on the write,strtoupper()inside the lookup SQL). A different half of A2 is live — see below.find_rates, stored un-strippedWC_Tax::prepare_tax_rate()singlestax_rate_stateout forsanitize_key()before storing, so the column holds a more normalized value than either reader asked for.Defects fixed
[a-z0-9_-]duplicated a rate row on every calculation. The lookup stripped spaces; core's storage runssanitize_key(). Those agree for'N Y'and disagree for'N.Y.'and for anything non-ASCII, so the row inserted could never be found again.Address::state_compact()now mirrors the core function rather than approximating it.sanitize_text_field()removes did the same — the write appliedwc_clean(), the lookup did not. Sanitisation now happens once, inside the shared projection.allow_street_address_for_matched_rates()could not see rowscreate_or_update_tax_rate()had just written. It normalised the city but not the state. The rows existed; thewoocommerce_matched_ratespath — price display, shipping tax, coupon math — returned no rates for those addresses, while itemized cart totals stayed correct. That asymmetry is the reason the test suite checks the two methods against each other rather than each in isolation.Two observations for R2, recorded but not acted on
allow_street_address_for_matched_rates()passes no street toWC_Tax::find_rates()— that function takes no street argument, and the value the method unpacked from the tuple was never read. What it actually does is accept a location of four or more elements whereWC_Tax::get_rates_from_location()accepts exactly four, supplying the rates core skips when this plugin's five-element taxable address is in play. It is an arity workaround, not a street feature. This independently corroborates the July measurement that street never moves TaxJar's jurisdiction. It is public and stays; the docblock now says what it does.array_keys( $wc_rates )[ $priority - 1 ], against afind_rates()that returns one rate per priority). The lookup for priority N only lands on the intended row when priorities 1..N-1 already exist. True of our own caller, so it works — but it is the constraint any re-keying has to keep or replace. Pinned by a test.Behaviour changes
get_backend_address()no longer upper-cases postcode, city and street. This was the admin path's own historic behaviour, unmatched byget_address(), kept only because the city reaches the rate-table city column — which is now upper-cased at both ends. The admin recalculate and the cart put identically-cased values on the wire. This changes the request JSON, so one generation of themd5( $json )request cache is invalidated (nothing else; the same input recalculates to the same result).tax_rate_countryis 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".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.Backward compatibility
Public and externally exposed surface touched. No signature changes.
create_or_update_tax_rate(),allow_street_address_for_matched_rates()andget_taxable_address()keep their signatures and return shapes.get_backend_address()isprotectedand keeps its array shape; only the casing of three values changes.normalize_city()is deprecated, not removed. It isprotected static, so an out-of-repo subclass may call it, and removing it would break that subclass on load. It keeps delegating to the value object and keeps returning non-string input untouched. Per AGENTS.md: deprecate, don't rename or delete.woocommerce_taxjar_enable_florida_shipping_taxstill fires with the same argument at the same point.$locationraw, deliberately. Routing it through the value object would make it fire for a lower-cased'us'/'fl'where it does not today — a change to what tax is charged, not to how a row is keyed. Out of this PR's scope; flagged for R2.'NY'before and after — only the lookup changed. A comma-list postcode is the one case where a new row shape is written, and the old row was unreachable from the TaxJar request anyway.WC()->…dereference, no$post, no$wp_query.WC_Taxandwc_clean()/wc_normalize_postcode()/sanitize_key()are all already required by these methods.wp_woocommerce_tax_ratesis a per-site table and no option access was added or moved.Testing instructions
composer test→ 331 tests / 793 assertions green (291 on the base branch, +40, zero regressions).Fail-first confirmed by restoring only the two production files and re-running: 44 tests fail — the 11 value-object round-trip cases, the
sanitize_key()state case, the 25 deprecation-notice expectations, bothget_backend_address()casing tests, and the 6 rate-table cases whose behaviour this PR changes.PHPCS: zero new violations, net −3 (branch baseline 3813 → 3810), measured by diffing
--report=sourcebefore and after.php -lclean on both changed production files.Manual check worth doing on a store: enable automated taxes, check out twice to the same US address, and confirm
wp_woocommerce_tax_ratesgains one row per priority on the first checkout and none on the second.