Skip to content

Commit a3a1130

Browse files
committed
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.
1 parent fbb5ed0 commit a3a1130

4 files changed

Lines changed: 560 additions & 43 deletions

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
= 3.6.10 - 2026-xx-xx =
44
* Tweak - WooCommerce 11.0 Compatibility.
5+
* Tweak - Reduce redundant TaxJar API calls by matching cached tax responses across the cart and order paths and ignoring irrelevant formatting differences.
56

67
= 3.6.9 - 2026-07-20 =
78
* Fix - Prevent a rare fatal error when the WooCommerce Store API cannot be initialized on incomplete installations.

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

Lines changed: 232 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -587,9 +587,11 @@ public function calculate_totals( $wc_cart_object ) {
587587
}
588588

589589
foreach ( $wc_cart_object->get_cart() as $cart_item_key => $cart_item ) {
590-
$product = $cart_item['data'];
591-
$line_item_key = $product->get_id() . '-' . $cart_item_key;
592-
if ( isset( $taxes['line_items'][ $line_item_key ] ) && ! $taxes['line_items'][ $line_item_key ]->combined_tax_rate ) {
590+
$product = $cart_item['data'];
591+
// get_line_items() keys by cart item key and stores the canonical TaxJar ID
592+
// under 'id'; the response is keyed by that canonical ID.
593+
$line_item_key = $line_items[ $cart_item_key ]['id'] ?? null;
594+
if ( null !== $line_item_key && isset( $taxes['line_items'][ $line_item_key ] ) && ! $taxes['line_items'][ $line_item_key ]->combined_tax_rate ) {
593595
if ( method_exists( $product, 'set_tax_status' ) ) {
594596
$product->set_tax_status( 'none' ); // Woo 3.0+
595597
} else {
@@ -650,9 +652,10 @@ public function calculate_backend_totals( $order_id ) {
650652
* @var WC_Order_Item_Product $item Product Order Item.
651653
*/
652654
foreach ( $order->get_items() as $item_key => $item ) {
653-
$product_id = $item->get_product_id();
654-
$line_item_key = $product_id . '-' . $item_key;
655-
if ( isset( $taxes['rate_ids'][ $line_item_key ] ) ) {
655+
// get_backend_line_items() keys by order item ID and stores the canonical
656+
// TaxJar ID under 'id'; the response is keyed by that canonical ID.
657+
$line_item_key = $line_items[ $item_key ]['id'] ?? null;
658+
if ( null !== $line_item_key && isset( $taxes['rate_ids'][ $line_item_key ] ) ) {
656659
$rate_id = $taxes['rate_ids'][ $line_item_key ];
657660
$item_tax = new WC_Order_Item_Tax();
658661
$item_tax->set_rate( $rate_id );
@@ -946,10 +949,13 @@ protected function get_backend_address() {
946949
/**
947950
* Get line items at checkout
948951
*
949-
* Unchanged from the TaxJar plugin.
952+
* Based on the TaxJar plugin, with canonical line item IDs added.
950953
* See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L645
951954
*
952-
* @return array
955+
* @param WC_Cart $wc_cart_object Cart object.
956+
*
957+
* @return array Line items keyed by cart item key. Each item's 'id' is the
958+
* canonical TaxJar line item ID, not the cart item key.
953959
*/
954960
protected function get_line_items( $wc_cart_object ) {
955961
$line_items = array();
@@ -1004,29 +1010,29 @@ protected function get_line_items( $wc_cart_object ) {
10041010
$this->_log( 'Tax location override for product ' . $id . ': ' . $default_location . ' -> ' . $tax_location );
10051011
}
10061012

1007-
array_push(
1008-
$line_items,
1009-
array(
1010-
'id' => $id . '-' . $cart_item_key,
1011-
'quantity' => $quantity,
1012-
'product_tax_code' => $tax_code,
1013-
'unit_price' => $unit_price,
1014-
'discount' => $discount,
1015-
'tax_location' => $tax_location,
1016-
)
1013+
$line_items[ $cart_item_key ] = array(
1014+
'id' => $id,
1015+
'quantity' => $quantity,
1016+
'product_tax_code' => $tax_code,
1017+
'unit_price' => $unit_price,
1018+
'discount' => $discount,
1019+
'tax_location' => $tax_location,
10171020
);
10181021
}
10191022

1020-
return $line_items;
1023+
return $this->assign_canonical_line_item_ids( $line_items );
10211024
}
10221025

10231026
/**
10241027
* Get line items for backend orders
10251028
*
1026-
* Unchanged from the TaxJar plugin.
1029+
* Based on the TaxJar plugin, with canonical line item IDs added.
10271030
* See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L695
10281031
*
1029-
* @return array
1032+
* @param WC_Order $order Order object.
1033+
*
1034+
* @return array Line items keyed by order item ID. Each item's 'id' is the
1035+
* canonical TaxJar line item ID, not the order item ID.
10301036
*/
10311037
protected function get_backend_line_items( $order ) {
10321038
$line_items = array();
@@ -1069,20 +1075,17 @@ protected function get_backend_line_items( $order ) {
10691075
}
10701076

10711077
if ( $unit_price ) {
1072-
array_push(
1073-
$line_items,
1074-
array(
1075-
'id' => $id . '-' . $item_key,
1076-
'quantity' => $quantity,
1077-
'product_tax_code' => $tax_code,
1078-
'unit_price' => $unit_price,
1079-
'discount' => $discount,
1080-
'tax_location' => $tax_location,
1081-
)
1078+
$line_items[ $item_key ] = array(
1079+
'id' => $id,
1080+
'quantity' => $quantity,
1081+
'product_tax_code' => $tax_code,
1082+
'unit_price' => $unit_price,
1083+
'discount' => $discount,
1084+
'tax_location' => $tax_location,
10821085
);
10831086
}
10841087
}
1085-
return $line_items;
1088+
return $this->assign_canonical_line_item_ids( $line_items );
10861089
}
10871090

10881091
protected function get_line_item( $id, $line_items ) {
@@ -1094,6 +1097,195 @@ protected function get_line_item( $id, $line_items ) {
10941097
return null;
10951098
}
10961099

1100+
/**
1101+
* Replace each line item's product ID with a canonical TaxJar line item ID.
1102+
*
1103+
* The cart path used to key line items by WooCommerce's cart item key and the
1104+
* order path by the numeric order item ID, so the same basket produced two
1105+
* different request bodies — and therefore two different cache keys — depending
1106+
* on which path built it. TaxJar treats `id` as an opaque echo field, so the two
1107+
* paths can agree on one value derived purely from the tax-relevant inputs:
1108+
* product, tax code, quantity, unit price, discount and tax location.
1109+
*
1110+
* Format is `<product_id>-<fingerprint>-<occurrence>`. The product ID stays the
1111+
* first `-` segment because `get_itemized_tax_rates()` recovers the product from
1112+
* it, and `override_cart_item_tax_rates()` / `override_order_item_taxes()` match
1113+
* on the `<product_id>-` prefix. The occurrence counter keeps two otherwise
1114+
* identical lines — an order can legitimately hold the same product twice at the
1115+
* same price — distinct, so neither loses its rate.
1116+
*
1117+
* @since 3.4.0
1118+
*
1119+
* @param array $line_items Line items whose 'id' is currently the bare product ID.
1120+
*
1121+
* @return array The same array with 'id' expanded to the canonical ID.
1122+
*/
1123+
private function assign_canonical_line_item_ids( array $line_items ): array {
1124+
$occurrences = array();
1125+
1126+
foreach ( $line_items as $key => $line_item ) {
1127+
$product_id = $line_item['id'];
1128+
1129+
$fingerprint = hash(
1130+
'md5',
1131+
(string) wp_json_encode(
1132+
array(
1133+
'product_id' => (string) $product_id,
1134+
'product_tax_code' => $this->normalize_cache_string( $line_item['product_tax_code'] ),
1135+
'quantity' => $this->normalize_cache_number( $line_item['quantity'] ),
1136+
'unit_price' => $this->normalize_cache_number( $line_item['unit_price'] ),
1137+
'discount' => $this->normalize_cache_number( $line_item['discount'] ),
1138+
'tax_location' => $this->normalize_cache_string( $line_item['tax_location'] ),
1139+
)
1140+
)
1141+
);
1142+
1143+
$occurrences[ $fingerprint ] = isset( $occurrences[ $fingerprint ] ) ? $occurrences[ $fingerprint ] + 1 : 0;
1144+
1145+
$line_items[ $key ]['id'] = $product_id . '-' . substr( $fingerprint, 0, 12 ) . '-' . $occurrences[ $fingerprint ];
1146+
}
1147+
1148+
return $line_items;
1149+
}
1150+
1151+
/**
1152+
* Normalize a string for cache-key purposes.
1153+
*
1154+
* Trims, collapses runs of whitespace and upper-cases, so that "beverly hills",
1155+
* "Beverly Hills" and "Beverly Hills " all hash the same. Used only to derive
1156+
* cache keys and line item fingerprints — never to build the request sent to
1157+
* TaxJar, which keeps the merchant's values verbatim.
1158+
*
1159+
* @since 3.4.0
1160+
*
1161+
* @param mixed $value Value to normalize.
1162+
*
1163+
* @return string
1164+
*/
1165+
private function normalize_cache_string( $value ): string {
1166+
if ( is_bool( $value ) ) {
1167+
$value = $value ? '1' : '0';
1168+
}
1169+
1170+
if ( ! is_scalar( $value ) && null !== $value ) {
1171+
return '';
1172+
}
1173+
1174+
$value = preg_replace( '/\s+/u', ' ', trim( (string) $value ) );
1175+
1176+
return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $value, 'UTF-8' ) : strtoupper( $value );
1177+
}
1178+
1179+
/**
1180+
* Normalize a numeric value for cache-key purposes.
1181+
*
1182+
* Amounts reach the request body as `wc_format_decimal()` strings with varying
1183+
* precision, so 5, "5" and "5.00" are the same money but three different bytes.
1184+
* Collapses them to one representation. Non-numeric input is left to
1185+
* normalize_cache_string() so a value that is not really a number cannot be
1186+
* silently reinterpreted — notably ZIP codes, where "01234" must never become
1187+
* "1234".
1188+
*
1189+
* @since 3.4.0
1190+
*
1191+
* @param mixed $value Value to normalize.
1192+
*
1193+
* @return string
1194+
*/
1195+
private function normalize_cache_number( $value ): string {
1196+
if ( ! is_numeric( $value ) ) {
1197+
return $this->normalize_cache_string( $value );
1198+
}
1199+
1200+
$normalized = number_format( (float) $value, 6, '.', '' );
1201+
1202+
if ( false !== strpos( $normalized, '.' ) ) {
1203+
$normalized = rtrim( rtrim( $normalized, '0' ), '.' );
1204+
}
1205+
1206+
// rtrim() eats the whole string for 0.000000, and -0 is still 0.
1207+
if ( '' === $normalized || '-' === $normalized || '-0' === $normalized ) {
1208+
$normalized = '0';
1209+
}
1210+
1211+
return $normalized;
1212+
}
1213+
1214+
/**
1215+
* Build the cache signature for a TaxJar request body.
1216+
*
1217+
* Hashing the raw JSON makes the cache byte-sensitive: a differently-cased city,
1218+
* a stray double space in a street address or "5" versus "5.00" for the same
1219+
* price all miss a cache entry that would have answered correctly. This projects
1220+
* the body onto a canonical form first — whitespace and case folded, amounts
1221+
* given one representation, key order fixed, line items sorted — so equivalent
1222+
* requests share one entry.
1223+
*
1224+
* Only the cache key is derived from this. The body sent to TaxJar is untouched.
1225+
*
1226+
* @since 3.4.0
1227+
*
1228+
* @param string $json Encoded TaxJar request body.
1229+
*
1230+
* @return string Canonical signature, or the input unchanged if it will not decode.
1231+
*/
1232+
private function get_cache_signature( $json ): string {
1233+
$body = json_decode( (string) $json, true );
1234+
1235+
if ( ! is_array( $body ) ) {
1236+
return (string) $json;
1237+
}
1238+
1239+
return (string) wp_json_encode( $this->canonicalize_cache_payload( $body ) );
1240+
}
1241+
1242+
/**
1243+
* Recursively canonicalize a request body for cache-key derivation.
1244+
*
1245+
* Numeric normalization is applied by field name rather than by looking at the
1246+
* value, because several address fields hold digit-only strings that must keep
1247+
* their exact form (a leading-zero ZIP above all).
1248+
*
1249+
* @since 3.4.0
1250+
*
1251+
* @param mixed $value Value to canonicalize.
1252+
* @param string $key Key the value was found under.
1253+
*
1254+
* @return mixed
1255+
*/
1256+
private function canonicalize_cache_payload( $value, $key = '' ) {
1257+
$numeric_fields = array( 'amount', 'shipping', 'quantity', 'unit_price', 'discount' );
1258+
1259+
if ( is_array( $value ) ) {
1260+
$canonical = array();
1261+
1262+
foreach ( $value as $child_key => $child_value ) {
1263+
$canonical[ $child_key ] = $this->canonicalize_cache_payload( $child_value, (string) $child_key );
1264+
}
1265+
1266+
if ( wp_is_numeric_array( $canonical ) ) {
1267+
// Lists (line items, nexus addresses) carry no meaning in their order,
1268+
// so sort them to keep the signature independent of how they were built.
1269+
usort(
1270+
$canonical,
1271+
function ( $first, $second ) {
1272+
return strcmp( (string) wp_json_encode( $first ), (string) wp_json_encode( $second ) );
1273+
}
1274+
);
1275+
} else {
1276+
ksort( $canonical );
1277+
}
1278+
1279+
return $canonical;
1280+
}
1281+
1282+
if ( in_array( $key, $numeric_fields, true ) ) {
1283+
return $this->normalize_cache_number( $value );
1284+
}
1285+
1286+
return $this->normalize_cache_string( $value );
1287+
}
1288+
10971289
/**
10981290
* Override tax rates for individual cart items.
10991291
*
@@ -1122,7 +1314,7 @@ public function override_cart_item_tax_rates( $item_tax_rates, $item, $cart ) {
11221314
$product_id = $product->get_id();
11231315

11241316
// Find the matching line_item_key in response_rate_ids.
1125-
// Format is "product_id-cart_item_key". The trailing "-" delimiter prevents
1317+
// Format is "product_id-fingerprint-occurrence". The trailing "-" delimiter prevents
11261318
// false prefix matches (e.g. product ID 1 won't match "10-xyz" because "1-" != "10").
11271319
// First-match-wins is safe: if the same product ID appears multiple times (e.g.
11281320
// two bookings), they share the same tax_location and thus the same tax rates.
@@ -1189,7 +1381,7 @@ public function override_order_item_taxes( $item, $calculate_tax_for ) {
11891381
return;
11901382
}
11911383

1192-
// Find matching rate_ids by product_id prefix (format: "product_id-cart_item_key").
1384+
// Find matching rate_ids by product_id prefix (format: "product_id-fingerprint-occurrence").
11931385
// The trailing "-" delimiter prevents false prefix matches between IDs (e.g. 1 vs 10).
11941386
// First-match-wins is safe: same product always shares the same tax_location and rates.
11951387
$matching_rate_ids = null;
@@ -1622,7 +1814,9 @@ public function calculate_tax( $options = array() ) {
16221814
if ( empty( $line_items ) ) {
16231815
$body['amount'] = 0.01;
16241816
} else {
1625-
$body['line_items'] = $line_items;
1817+
// Line items arrive keyed by cart item key / order item ID; TaxJar expects a
1818+
// JSON array, and a string-keyed PHP array would encode as an object.
1819+
$body['line_items'] = array_values( $line_items );
16261820
}
16271821

16281822
$response = $this->smartcalcs_cache_request( wp_json_encode( $body ), $from_state );
@@ -1977,7 +2171,8 @@ public function validate_taxjar_request( $json ) {
19772171
/**
19782172
* Wrap SmartCalcs API requests in a transient-based caching layer.
19792173
*
1980-
* Unchanged from the TaxJar plugin.
2174+
* Based on the TaxJar plugin. The cache key is derived from a canonical
2175+
* projection of the body rather than its raw bytes — see get_cache_signature().
19812176
* See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/4b481f5/includes/class-wc-taxjar-integration.php#L451
19822177
*
19832178
* @param $json
@@ -1986,7 +2181,7 @@ public function validate_taxjar_request( $json ) {
19862181
* @return mixed|WP_Error
19872182
*/
19882183
public function smartcalcs_cache_request( $json, $from_state ) {
1989-
$cache_key = 'tj_tax_' . hash( 'md5', $json );
2184+
$cache_key = 'tj_tax_' . hash( 'md5', $this->get_cache_signature( $json ) );
19902185
$zip_state_cache_key = false;
19912186
$request = json_decode( $json );
19922187
$to_zip = isset( $request->to_zip ) ? (string) $request->to_zip : false;

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ This plugin relies on the following external services:
7272

7373
= 3.6.10 - 2026-xx-xx =
7474
* Tweak - WooCommerce 11.0 Compatibility.
75+
* Tweak - Reduce redundant TaxJar API calls by matching cached tax responses across the cart and order paths and ignoring irrelevant formatting differences.
7576

7677
= 3.6.9 - 2026-07-20 =
7778
* Fix - Prevent a rare fatal error when the WooCommerce Store API cannot be initialized on incomplete installations.

0 commit comments

Comments
 (0)