@@ -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 ;
0 commit comments