Skip to content

Commit a302ee3

Browse files
bartechiyut
andauthored
Tweak - Match TaxJar cache entries across cart and order paths (WOOTAX-258) (#2983)
* WOOTAX-258: match TaxJar cache entries across cart and order paths The transient cache key was an md5 of the raw request body, so two requests that would receive the same answer from TaxJar routinely missed each other. Two causes, fixed separately: * The line item `id` was built from WooCommerce's cart item key on the cart path and from the numeric order item ID on the order path, so the same basket produced two different bodies. Both paths now derive `id` from the tax-relevant inputs alone (product, tax code, quantity, unit price, discount, tax location), with an occurrence counter to keep genuinely identical lines distinct. The product ID stays the first `-` segment, which get_itemized_tax_rates() and the rate-override hooks rely on. * The key was byte-sensitive, so a differently-cased city, a stray double space or "5" vs "5.00" split the cache. The key now comes from a canonical projection of the body: whitespace and case folded, amounts given one representation, key order fixed, line items sorted. Numeric normalization is applied by field name so a leading-zero ZIP is never reinterpreted. The body sent to TaxJar is unchanged apart from `id`, which TaxJar treats as an opaque echo field. * WOOTAX-258: address PR review — @SInCE stamps and cache-hit coverage Review follow-ups on #2983: - The five new private cache-key helpers were stamped `@since 3.4.0`; the release in progress is 3.6.10. - `calculate_backend_totals()` passes the order-item-ID-keyed map from `get_backend_line_items()` straight into `calculate_tax()` without going through `group_items_by_location()`, so `array_values()` is the only thing keeping `line_items` a JSON array rather than an object on every admin-side request. The one existing `calculate_tax()` test returns at the cross-state guard and never reaches it. Adds a test that captures the encoded body and asserts a zero-indexed list, checking the raw JSON as well as the decoded array since `json_decode()` hides the array/object distinction. - `smartcalcs_cache_request()` had no coverage at all: the existing tests compare `get_cache_signature()` outputs in isolation, verifying the ingredients but never the result. Adds a test that counts `smartcalcs_request()` invocations across two equivalent-but-byte-different bodies and asserts one HTTP call plus a populated signature transient. It asserts specifically on `tj_tax_<md5(signature)>` and on the *absence* of the `tj_tax_<zip>_<state>` key, which only ever caches 400 zip-to-state mismatches and would otherwise mask a broken signature. Both new tests were verified to fail against mutated guards. * update since docs * Chore: Renumber unreleased release from 3.7.0 to 3.6.12. The pending TaxJar response-cache work is retargeted from the 3.7.0 minor to the 3.6.x patch line. Update the unreleased changelog and readme headings, and move the five @SInCE stamps on the new cache-normalization helpers so they document the version that will actually ship. * Fix - Re-key the non-taxable record onto canonical TaxJar line item ids. get_line_items() and get_backend_line_items() record exempt lines under the context-specific `<product_id>-<cart_item_key>` / `<product_id>-<order_item_id>` id, but assign_canonical_line_item_ids() then rewrites each line item's id to `<product_id>-<fingerprint>-<occurrence>`, and that canonical id is what TaxJar echoes back. get_itemized_tax_rates() looks the record up by the echoed id, so the two key shapes could never match: the guard never fired and the 0% breakdown line of a non-taxable product overwrote the shared Standard-class rate row. That silently reverted the unreleased 3.6.12 fix for mixed taxable/non-taxable carts. Both call sites now move the record onto the canonical ids, restoring the invariant the property docblock already states ("Keyed by TaxJar line item id"). Applies @Abdalsalaam's review suggestions on #2983 verbatim, at both call sites. Also updates three tests that indexed get_line_items()'s return positionally. This PR keys that array by cart item key (and the backend one by order item ID) and pins it in test_get_line_items_is_keyed_by_cart_item_key, so the tests now take the single element rather than element zero. Full suite: 399 tests, 975 assertions, green. --------- Co-authored-by: Iyut <iyut85@yahoo.com>
1 parent 028ceb6 commit a302ee3

4 files changed

Lines changed: 748 additions & 46 deletions

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* Fix - Calculate tax correctly for carts mixing taxable and non-taxable products, so a non-taxable product no longer resets the standard tax rate to zero.
1515
* Tweak - Centralize TaxJar address handling in an internal value object. No change to tax calculation.
1616
* Tweak - Store tax rate rows against the postcode the rate was quoted for when the address postcode holds more than one comma-separated value.
17+
* Tweak - Reduce redundant TaxJar API calls by matching cached tax responses across the cart and order paths and ignoring irrelevant formatting differences.
1718

1819
= 3.6.11 - 2026-08-05 =
1920
* 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: 257 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -617,9 +617,11 @@ public function calculate_totals( $wc_cart_object ) {
617617
}
618618

619619
foreach ( $wc_cart_object->get_cart() as $cart_item_key => $cart_item ) {
620-
$product = $cart_item['data'];
621-
$line_item_key = $product->get_id() . '-' . $cart_item_key;
622-
if ( isset( $taxes['line_items'][ $line_item_key ] ) && ! $taxes['line_items'][ $line_item_key ]->combined_tax_rate ) {
620+
$product = $cart_item['data'];
621+
// get_line_items() keys by cart item key and stores the canonical TaxJar ID
622+
// under 'id'; the response is keyed by that canonical ID.
623+
$line_item_key = $line_items[ $cart_item_key ]['id'] ?? null;
624+
if ( null !== $line_item_key && isset( $taxes['line_items'][ $line_item_key ] ) && ! $taxes['line_items'][ $line_item_key ]->combined_tax_rate ) {
623625
if ( method_exists( $product, 'set_tax_status' ) ) {
624626
$product->set_tax_status( 'none' ); // Woo 3.0+
625627
} else {
@@ -680,9 +682,10 @@ public function calculate_backend_totals( $order_id ) {
680682
* @var WC_Order_Item_Product $item Product Order Item.
681683
*/
682684
foreach ( $order->get_items() as $item_key => $item ) {
683-
$product_id = $item->get_product_id();
684-
$line_item_key = $product_id . '-' . $item_key;
685-
if ( isset( $taxes['rate_ids'][ $line_item_key ] ) ) {
685+
// get_backend_line_items() keys by order item ID and stores the canonical
686+
// TaxJar ID under 'id'; the response is keyed by that canonical ID.
687+
$line_item_key = $line_items[ $item_key ]['id'] ?? null;
688+
if ( null !== $line_item_key && isset( $taxes['rate_ids'][ $line_item_key ] ) ) {
686689
$rate_id = $taxes['rate_ids'][ $line_item_key ];
687690
$item_tax = new WC_Order_Item_Tax();
688691
$item_tax->set_rate( $rate_id );
@@ -967,10 +970,13 @@ protected function get_backend_address() {
967970
/**
968971
* Get line items at checkout
969972
*
970-
* Unchanged from the TaxJar plugin.
973+
* Based on the TaxJar plugin, with canonical line item IDs added.
971974
* See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L645
972975
*
973-
* @return array
976+
* @param WC_Cart $wc_cart_object Cart object.
977+
*
978+
* @return array Line items keyed by cart item key. Each item's 'id' is the
979+
* canonical TaxJar line item ID, not the cart item key.
974980
*/
975981
protected function get_line_items( $wc_cart_object ) {
976982
$line_items = array();
@@ -1049,29 +1055,42 @@ protected function get_line_items( $wc_cart_object ) {
10491055
$this->_log( 'Tax location override for product ' . $id . ': ' . $default_location . ' -> ' . $tax_location );
10501056
}
10511057

1052-
array_push(
1053-
$line_items,
1054-
array(
1055-
'id' => $id . '-' . $cart_item_key,
1056-
'quantity' => $quantity,
1057-
'product_tax_code' => $tax_code,
1058-
'unit_price' => $unit_price,
1059-
'discount' => $discount,
1060-
'tax_location' => $tax_location,
1061-
)
1058+
$line_items[ $cart_item_key ] = array(
1059+
'id' => $id,
1060+
'quantity' => $quantity,
1061+
'product_tax_code' => $tax_code,
1062+
'unit_price' => $unit_price,
1063+
'discount' => $discount,
1064+
'tax_location' => $tax_location,
10621065
);
10631066
}
10641067

1068+
$line_items = $this->assign_canonical_line_item_ids( $line_items );
1069+
1070+
// The exempt record was keyed while ids were still context-specific; move it onto
1071+
// the canonical ids, which is what get_itemized_tax_rates() looks up.
1072+
$non_taxable = array();
1073+
foreach ( array_keys( $this->non_taxable_line_items ) as $legacy_key ) {
1074+
$item_key = substr( $legacy_key, (int) strpos( $legacy_key, '-' ) + 1 );
1075+
if ( isset( $line_items[ $item_key ] ) ) {
1076+
$non_taxable[ $line_items[ $item_key ]['id'] ] = true;
1077+
}
1078+
}
1079+
$this->non_taxable_line_items = $non_taxable;
1080+
10651081
return $line_items;
10661082
}
10671083

10681084
/**
10691085
* Get line items for backend orders
10701086
*
1071-
* Unchanged from the TaxJar plugin.
1087+
* Based on the TaxJar plugin, with canonical line item IDs added.
10721088
* See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L695
10731089
*
1074-
* @return array
1090+
* @param WC_Order $order Order object.
1091+
*
1092+
* @return array Line items keyed by order item ID. Each item's 'id' is the
1093+
* canonical TaxJar line item ID, not the order item ID.
10751094
*/
10761095
protected function get_backend_line_items( $order ) {
10771096
$line_items = array();
@@ -1120,19 +1139,30 @@ protected function get_backend_line_items( $order ) {
11201139
}
11211140

11221141
if ( $unit_price ) {
1123-
array_push(
1124-
$line_items,
1125-
array(
1126-
'id' => $id . '-' . $item_key,
1127-
'quantity' => $quantity,
1128-
'product_tax_code' => $tax_code,
1129-
'unit_price' => $unit_price,
1130-
'discount' => $discount,
1131-
'tax_location' => $tax_location,
1132-
)
1142+
$line_items[ $item_key ] = array(
1143+
'id' => $id,
1144+
'quantity' => $quantity,
1145+
'product_tax_code' => $tax_code,
1146+
'unit_price' => $unit_price,
1147+
'discount' => $discount,
1148+
'tax_location' => $tax_location,
11331149
);
11341150
}
11351151
}
1152+
1153+
$line_items = $this->assign_canonical_line_item_ids( $line_items );
1154+
1155+
// The exempt record was keyed while ids were still context-specific; move it onto
1156+
// the canonical ids, which is what get_itemized_tax_rates() looks up.
1157+
$non_taxable = array();
1158+
foreach ( array_keys( $this->non_taxable_line_items ) as $legacy_key ) {
1159+
$item_key = substr( $legacy_key, (int) strpos( $legacy_key, '-' ) + 1 );
1160+
if ( isset( $line_items[ $item_key ] ) ) {
1161+
$non_taxable[ $line_items[ $item_key ]['id'] ] = true;
1162+
}
1163+
}
1164+
$this->non_taxable_line_items = $non_taxable;
1165+
11361166
return $line_items;
11371167
}
11381168

@@ -1145,6 +1175,195 @@ protected function get_line_item( $id, $line_items ) {
11451175
return null;
11461176
}
11471177

1178+
/**
1179+
* Replace each line item's product ID with a canonical TaxJar line item ID.
1180+
*
1181+
* The cart path used to key line items by WooCommerce's cart item key and the
1182+
* order path by the numeric order item ID, so the same basket produced two
1183+
* different request bodies — and therefore two different cache keys — depending
1184+
* on which path built it. TaxJar treats `id` as an opaque echo field, so the two
1185+
* paths can agree on one value derived purely from the tax-relevant inputs:
1186+
* product, tax code, quantity, unit price, discount and tax location.
1187+
*
1188+
* Format is `<product_id>-<fingerprint>-<occurrence>`. The product ID stays the
1189+
* first `-` segment because `get_itemized_tax_rates()` recovers the product from
1190+
* it, and `override_cart_item_tax_rates()` / `override_order_item_taxes()` match
1191+
* on the `<product_id>-` prefix. The occurrence counter keeps two otherwise
1192+
* identical lines — an order can legitimately hold the same product twice at the
1193+
* same price — distinct, so neither loses its rate.
1194+
*
1195+
* @since 3.6.12
1196+
*
1197+
* @param array $line_items Line items whose 'id' is currently the bare product ID.
1198+
*
1199+
* @return array The same array with 'id' expanded to the canonical ID.
1200+
*/
1201+
private function assign_canonical_line_item_ids( array $line_items ): array {
1202+
$occurrences = array();
1203+
1204+
foreach ( $line_items as $key => $line_item ) {
1205+
$product_id = $line_item['id'];
1206+
1207+
$fingerprint = hash(
1208+
'md5',
1209+
(string) wp_json_encode(
1210+
array(
1211+
'product_id' => (string) $product_id,
1212+
'product_tax_code' => $this->normalize_cache_string( $line_item['product_tax_code'] ),
1213+
'quantity' => $this->normalize_cache_number( $line_item['quantity'] ),
1214+
'unit_price' => $this->normalize_cache_number( $line_item['unit_price'] ),
1215+
'discount' => $this->normalize_cache_number( $line_item['discount'] ),
1216+
'tax_location' => $this->normalize_cache_string( $line_item['tax_location'] ),
1217+
)
1218+
)
1219+
);
1220+
1221+
$occurrences[ $fingerprint ] = isset( $occurrences[ $fingerprint ] ) ? $occurrences[ $fingerprint ] + 1 : 0;
1222+
1223+
$line_items[ $key ]['id'] = $product_id . '-' . substr( $fingerprint, 0, 12 ) . '-' . $occurrences[ $fingerprint ];
1224+
}
1225+
1226+
return $line_items;
1227+
}
1228+
1229+
/**
1230+
* Normalize a string for cache-key purposes.
1231+
*
1232+
* Trims, collapses runs of whitespace and upper-cases, so that "beverly hills",
1233+
* "Beverly Hills" and "Beverly Hills " all hash the same. Used only to derive
1234+
* cache keys and line item fingerprints — never to build the request sent to
1235+
* TaxJar, which keeps the merchant's values verbatim.
1236+
*
1237+
* @since 3.6.12
1238+
*
1239+
* @param mixed $value Value to normalize.
1240+
*
1241+
* @return string
1242+
*/
1243+
private function normalize_cache_string( $value ): string {
1244+
if ( is_bool( $value ) ) {
1245+
$value = $value ? '1' : '0';
1246+
}
1247+
1248+
if ( ! is_scalar( $value ) && null !== $value ) {
1249+
return '';
1250+
}
1251+
1252+
$value = preg_replace( '/\s+/u', ' ', trim( (string) $value ) );
1253+
1254+
return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $value, 'UTF-8' ) : strtoupper( $value );
1255+
}
1256+
1257+
/**
1258+
* Normalize a numeric value for cache-key purposes.
1259+
*
1260+
* Amounts reach the request body as `wc_format_decimal()` strings with varying
1261+
* precision, so 5, "5" and "5.00" are the same money but three different bytes.
1262+
* Collapses them to one representation. Non-numeric input is left to
1263+
* normalize_cache_string() so a value that is not really a number cannot be
1264+
* silently reinterpreted — notably ZIP codes, where "01234" must never become
1265+
* "1234".
1266+
*
1267+
* @since 3.6.12
1268+
*
1269+
* @param mixed $value Value to normalize.
1270+
*
1271+
* @return string
1272+
*/
1273+
private function normalize_cache_number( $value ): string {
1274+
if ( ! is_numeric( $value ) ) {
1275+
return $this->normalize_cache_string( $value );
1276+
}
1277+
1278+
$normalized = number_format( (float) $value, 6, '.', '' );
1279+
1280+
if ( false !== strpos( $normalized, '.' ) ) {
1281+
$normalized = rtrim( rtrim( $normalized, '0' ), '.' );
1282+
}
1283+
1284+
// rtrim() eats the whole string for 0.000000, and -0 is still 0.
1285+
if ( '' === $normalized || '-' === $normalized || '-0' === $normalized ) {
1286+
$normalized = '0';
1287+
}
1288+
1289+
return $normalized;
1290+
}
1291+
1292+
/**
1293+
* Build the cache signature for a TaxJar request body.
1294+
*
1295+
* Hashing the raw JSON makes the cache byte-sensitive: a differently-cased city,
1296+
* a stray double space in a street address or "5" versus "5.00" for the same
1297+
* price all miss a cache entry that would have answered correctly. This projects
1298+
* the body onto a canonical form first — whitespace and case folded, amounts
1299+
* given one representation, key order fixed, line items sorted — so equivalent
1300+
* requests share one entry.
1301+
*
1302+
* Only the cache key is derived from this. The body sent to TaxJar is untouched.
1303+
*
1304+
* @since 3.6.12
1305+
*
1306+
* @param string $json Encoded TaxJar request body.
1307+
*
1308+
* @return string Canonical signature, or the input unchanged if it will not decode.
1309+
*/
1310+
private function get_cache_signature( $json ): string {
1311+
$body = json_decode( (string) $json, true );
1312+
1313+
if ( ! is_array( $body ) ) {
1314+
return (string) $json;
1315+
}
1316+
1317+
return (string) wp_json_encode( $this->canonicalize_cache_payload( $body ) );
1318+
}
1319+
1320+
/**
1321+
* Recursively canonicalize a request body for cache-key derivation.
1322+
*
1323+
* Numeric normalization is applied by field name rather than by looking at the
1324+
* value, because several address fields hold digit-only strings that must keep
1325+
* their exact form (a leading-zero ZIP above all).
1326+
*
1327+
* @since 3.6.12
1328+
*
1329+
* @param mixed $value Value to canonicalize.
1330+
* @param string $key Key the value was found under.
1331+
*
1332+
* @return mixed
1333+
*/
1334+
private function canonicalize_cache_payload( $value, $key = '' ) {
1335+
$numeric_fields = array( 'amount', 'shipping', 'quantity', 'unit_price', 'discount' );
1336+
1337+
if ( is_array( $value ) ) {
1338+
$canonical = array();
1339+
1340+
foreach ( $value as $child_key => $child_value ) {
1341+
$canonical[ $child_key ] = $this->canonicalize_cache_payload( $child_value, (string) $child_key );
1342+
}
1343+
1344+
if ( wp_is_numeric_array( $canonical ) ) {
1345+
// Lists (line items, nexus addresses) carry no meaning in their order,
1346+
// so sort them to keep the signature independent of how they were built.
1347+
usort(
1348+
$canonical,
1349+
function ( $first, $second ) {
1350+
return strcmp( (string) wp_json_encode( $first ), (string) wp_json_encode( $second ) );
1351+
}
1352+
);
1353+
} else {
1354+
ksort( $canonical );
1355+
}
1356+
1357+
return $canonical;
1358+
}
1359+
1360+
if ( in_array( $key, $numeric_fields, true ) ) {
1361+
return $this->normalize_cache_number( $value );
1362+
}
1363+
1364+
return $this->normalize_cache_string( $value );
1365+
}
1366+
11481367
/**
11491368
* Override tax rates for individual cart items.
11501369
*
@@ -1173,7 +1392,7 @@ public function override_cart_item_tax_rates( $item_tax_rates, $item, $cart ) {
11731392
$product_id = $product->get_id();
11741393

11751394
// Find the matching line_item_key in response_rate_ids.
1176-
// Format is "product_id-cart_item_key". The trailing "-" delimiter prevents
1395+
// Format is "product_id-fingerprint-occurrence". The trailing "-" delimiter prevents
11771396
// false prefix matches (e.g. product ID 1 won't match "10-xyz" because "1-" != "10").
11781397
// First-match-wins is safe: if the same product ID appears multiple times (e.g.
11791398
// two bookings), they share the same tax_location and thus the same tax rates.
@@ -1240,7 +1459,7 @@ public function override_order_item_taxes( $item, $calculate_tax_for ) {
12401459
return;
12411460
}
12421461

1243-
// Find matching rate_ids by product_id prefix (format: "product_id-cart_item_key").
1462+
// Find matching rate_ids by product_id prefix (format: "product_id-fingerprint-occurrence").
12441463
// The trailing "-" delimiter prevents false prefix matches between IDs (e.g. 1 vs 10).
12451464
// First-match-wins is safe: same product always shares the same tax_location and rates.
12461465
$matching_rate_ids = null;
@@ -1709,7 +1928,9 @@ public function calculate_tax( $options = array() ) {
17091928
if ( empty( $line_items ) ) {
17101929
$body['amount'] = 0.01;
17111930
} else {
1712-
$body['line_items'] = $line_items;
1931+
// Line items arrive keyed by cart item key / order item ID; TaxJar expects a
1932+
// JSON array, and a string-keyed PHP array would encode as an object.
1933+
$body['line_items'] = array_values( $line_items );
17131934
}
17141935

17151936
$response = $this->smartcalcs_cache_request( wp_json_encode( $body ), $from_state );
@@ -2113,7 +2334,8 @@ public function validate_taxjar_request( $json ) {
21132334
/**
21142335
* Wrap SmartCalcs API requests in a transient-based caching layer.
21152336
*
2116-
* Unchanged from the TaxJar plugin.
2337+
* Based on the TaxJar plugin. The cache key is derived from a canonical
2338+
* projection of the body rather than its raw bytes — see get_cache_signature().
21172339
* See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/4b481f5/includes/class-wc-taxjar-integration.php#L451
21182340
*
21192341
* @param $json
@@ -2122,7 +2344,7 @@ public function validate_taxjar_request( $json ) {
21222344
* @return mixed|WP_Error
21232345
*/
21242346
public function smartcalcs_cache_request( $json, $from_state ) {
2125-
$cache_key = 'tj_tax_' . hash( 'md5', $json );
2347+
$cache_key = 'tj_tax_' . hash( 'md5', $this->get_cache_signature( $json ) );
21262348
$zip_state_cache_key = false;
21272349
$request = json_decode( $json );
21282350
$to_zip = isset( $request->to_zip ) ? (string) $request->to_zip : false;

0 commit comments

Comments
 (0)